From 8095852c7dca90c6b18fcd149d054344732b23dc Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 2 Sep 2026 12:49:56 -0600 Subject: [PATCH] feat: add hand-owned model quirks overlay --- quirks.yaml | 880 ++++++++++++++++++++++++++++++++++++++++++ src/client/catalog.rs | 327 ++++++++++++++++ src/client/mod.rs | 1 + src/client/model.rs | 6 +- 4 files changed, 1211 insertions(+), 3 deletions(-) create mode 100644 quirks.yaml create mode 100644 src/client/catalog.rs diff --git a/quirks.yaml b/quirks.yaml new file mode 100644 index 0000000..65b2653 --- /dev/null +++ b/quirks.yaml @@ -0,0 +1,880 @@ +# Hand-owned model behavior overlay. +# +# Everything in this file is curated by hand and layered on top of API-sourced +# model data: oauth blocks, request patches, wire-name aliases, behavioral +# flags, and models that no public catalog provides. +# +# `models:` entries are self-complete — `rules` apply ONLY to API-sourced +# models, never to hand-owned `models:` entries. Legacy verbatim-preserved +# entries are grouped under a `# legacy — pruning candidates` comment. + +- provider: openai + rules: + - match: "gpt-5.6-*" + default_reasoning_effort: medium + - match: "gpt-5.5" + default_reasoning_effort: medium + - match: "gpt-5.5-pro" + default_reasoning_effort: high + - match: "gpt-5.4" + default_reasoning_effort: none + - match: "gpt-5.4-pro" + default_reasoning_effort: medium + - match: "gpt-5.4-mini" + default_reasoning_effort: none + - match: "gpt-5.4-nano" + default_reasoning_effort: none + - match: "gpt-5.3-codex" + default_reasoning_effort: medium + - match: "gpt-5.2" + default_reasoning_effort: none + - match: "gpt-5.2-pro" + default_reasoning_effort: medium + - match: "gpt-5.1" + default_reasoning_effort: none + - match: "gpt-5.1-chat-latest" + default_reasoning_effort: none + - match: "gpt-5" + default_reasoning_effort: medium + - match: "gpt-5-chat-latest" + default_reasoning_effort: medium + - match: "o4-mini" + system_prompt_prefix: Formatting re-enabled + default_reasoning_effort: medium + patch: + body: + max_tokens: null + temperature: null + top_p: null + - match: "o3" + system_prompt_prefix: Formatting re-enabled + patch: + body: + max_tokens: null + temperature: null + top_p: null + - match: "o3-mini" + system_prompt_prefix: Formatting re-enabled + patch: + body: + max_tokens: null + temperature: null + top_p: null + models: + - name: text-embedding-3-large + type: embedding + input_price: 0.13 + max_tokens_per_chunk: 8191 + default_chunk_size: 2000 + max_batch_size: 100 + - name: text-embedding-3-small + type: embedding + input_price: 0.02 + max_tokens_per_chunk: 8191 + default_chunk_size: 2000 + max_batch_size: 100 + # legacy — pruning candidates + - name: o4-mini-high + max_output_tokens: 100000 + real_name: o4-mini + max_input_tokens: 200000 + input_price: 1.1 + output_price: 4.4 + supports_vision: true + supports_function_calling: true + system_prompt_prefix: Formatting re-enabled + patch: + body: + reasoning_effort: high + max_tokens: null + temperature: null + top_p: null + - name: o3-high + real_name: o3 + max_input_tokens: 200000 + input_price: 2 + output_price: 8 + supports_vision: true + supports_function_calling: true + system_prompt_prefix: Formatting re-enabled + patch: + body: + reasoning_effort: high + max_tokens: null + temperature: null + top_p: null + - name: o3-mini-high + max_output_tokens: 100000 + real_name: o3-mini + max_input_tokens: 200000 + input_price: 1.1 + output_price: 4.4 + supports_vision: true + supports_function_calling: true + system_prompt_prefix: Formatting re-enabled + patch: + body: + reasoning_effort: high + max_tokens: null + temperature: null + top_p: null + +- provider: gemini + rules: + - match: "gemini-3.6-flash" + default_reasoning_effort: medium + - match: "gemini-3.5-flash" + default_reasoning_effort: medium + - match: "gemini-3-flash-preview" + default_reasoning_effort: high + - match: "gemini-3.1-flash-lite" + default_reasoning_effort: minimal + - match: "gemini-3.1-pro-preview" + default_reasoning_effort: high + - match: "gemini-3-pro-preview" + default_reasoning_effort: high + - match: "gemini-2.5-pro" + default_reasoning_effort: high + models: + - name: text-embedding-004 + type: embedding + input_price: 0 + max_tokens_per_chunk: 2048 + default_chunk_size: 1500 + max_batch_size: 100 + +- provider: claude + rules: + - match: "claude-*" + require_max_tokens: true + - match: "claude-opus-5" + default_reasoning_effort: high + - match: "claude-fable-*" + default_reasoning_effort: high + - match: "claude-opus-4-8" + default_reasoning_effort: high + - match: "claude-opus-4-7" + default_reasoning_effort: high + - match: "claude-opus-4-6" + default_reasoning_effort: high + - match: "claude-sonnet-5" + default_reasoning_effort: high + - match: "claude-sonnet-4-6" + default_reasoning_effort: high + variants: + - match: "claude-opus-4-6" + suffix: ":thinking" + real_name: claude-opus-4-6 + max_output_tokens: 24000 + reasoning_levels: [] + patch: + body: + temperature: null + top_p: null + thinking: + type: enabled + budget_tokens: 16000 + - match: "claude-sonnet-4-6" + suffix: ":thinking" + real_name: claude-sonnet-4-6 + max_output_tokens: 24000 + reasoning_levels: [] + patch: + body: + temperature: null + top_p: null + thinking: + type: enabled + budget_tokens: 16000 + - match: "claude-sonnet-4-5-20250929" + suffix: ":thinking" + real_name: claude-sonnet-4-5-20250929 + max_output_tokens: 24000 + reasoning_levels: [] + patch: + body: + temperature: null + top_p: null + thinking: + type: enabled + budget_tokens: 16000 + - match: "claude-haiku-4-5-20251001" + suffix: ":thinking" + real_name: claude-haiku-4-5-20251001 + max_output_tokens: 24000 + reasoning_levels: [] + patch: + body: + temperature: null + top_p: null + thinking: + type: enabled + budget_tokens: 16000 + - match: "claude-opus-4-5-20251101" + suffix: ":thinking" + real_name: claude-opus-4-5-20251101 + max_output_tokens: 24000 + reasoning_levels: [] + patch: + body: + temperature: null + top_p: null + thinking: + type: enabled + budget_tokens: 16000 + - match: "claude-opus-4-1-20250805" + suffix: ":thinking" + real_name: claude-opus-4-1-20250805 + max_output_tokens: 24000 + reasoning_levels: [] + patch: + body: + temperature: null + top_p: null + thinking: + type: enabled + budget_tokens: 16000 + - match: "claude-opus-4-20250514" + suffix: ":thinking" + real_name: claude-opus-4-20250514 + max_output_tokens: 24000 + reasoning_levels: [] + patch: + body: + temperature: null + top_p: null + thinking: + type: enabled + budget_tokens: 16000 + +- provider: mistral + models: + - name: mistral-embed + type: embedding + max_input_tokens: 8092 + input_price: 0.1 + max_tokens_per_chunk: 8092 + default_chunk_size: 2000 + +- provider: ai21 + models: + - name: jamba-large + max_input_tokens: 256000 + input_price: 2 + output_price: 8 + supports_function_calling: true + - name: jamba-mini + max_input_tokens: 256000 + input_price: 0.2 + output_price: 0.4 + supports_function_calling: true + +- provider: cohere + models: + - name: embed-v4.0 + type: embedding + input_price: 0.12 + max_tokens_per_chunk: 2048 + default_chunk_size: 2000 + max_batch_size: 96 + - name: embed-english-v3.0 + type: embedding + input_price: 0.1 + max_tokens_per_chunk: 512 + default_chunk_size: 1000 + max_batch_size: 96 + - name: embed-multilingual-v3.0 + type: embedding + input_price: 0.1 + max_tokens_per_chunk: 512 + default_chunk_size: 1000 + max_batch_size: 96 + - name: rerank-v3.5 + type: reranker + max_input_tokens: 4096 + - name: rerank-english-v3.0 + type: reranker + max_input_tokens: 4096 + - name: rerank-multilingual-v3.0 + type: reranker + max_input_tokens: 4096 + +- provider: xai + oauth: + client_id: b1a00492-073a-47ea-816f-4c329264a828 + authorize_url: https://auth.x.ai/oauth2/authorize + token_url: https://auth.x.ai/oauth2/token + scopes: + - openid + - profile + - email + - offline_access + - grok-cli:access + - api:access + redirect_port: 56121 + flow: pkce + token_request_format: form_url_encoded + extra_authorize_params: + plan: generic + referrer: coyote + echo_pkce_in_token_exchange: true + models: + - name: grok-4.20 + real_name: grok-4.20-multi-agent-0309 + input_price: 1.25 + output_price: 2.5 + max_input_tokens: 1000000 + supports_function_calling: true + - name: grok-4.20-reasoning + real_name: grok-4.20-0309-reasoning + input_price: 1.25 + output_price: 2.5 + max_input_tokens: 1000000 + supports_function_calling: true + - name: grok-4.20-non-reasoning + real_name: grok-4.20-0309-non-reasoning + input_price: 1.25 + output_price: 2.5 + max_input_tokens: 1000000 + supports_function_calling: true + +- provider: vertexai + rules: + - match: "claude-*" + require_max_tokens: true + - match: "gemini-3.5-flash" + default_reasoning_effort: medium + - match: "gemini-3-flash-preview" + default_reasoning_effort: high + - match: "gemini-3.1-flash-lite" + default_reasoning_effort: minimal + - match: "gemini-3.1-pro-preview" + default_reasoning_effort: high + - match: "gemini-3-pro-preview" + default_reasoning_effort: high + - match: "gemini-2.5-flash" + default_reasoning_effort: medium + - match: "gemini-2.5-pro" + default_reasoning_effort: high + - match: "claude-opus-5" + default_reasoning_effort: high + - match: "claude-fable-*" + default_reasoning_effort: high + - match: "claude-opus-4-8" + default_reasoning_effort: high + - match: "claude-opus-4-7" + default_reasoning_effort: high + - match: "claude-sonnet-5" + default_reasoning_effort: high + - match: "claude-sonnet-4-6" + default_reasoning_effort: high + variants: + - match: "claude-opus-4-6" + suffix: ":thinking" + real_name: claude-opus-4-6 + max_output_tokens: 24000 + supports_function_calling: false + reasoning_levels: [] + patch: + body: + temperature: null + top_p: null + thinking: + type: enabled + budget_tokens: 16000 + - match: "claude-sonnet-4-6" + suffix: ":thinking" + real_name: claude-sonnet-4-6 + max_output_tokens: 24000 + supports_function_calling: false + reasoning_levels: [] + patch: + body: + temperature: null + top_p: null + thinking: + type: enabled + budget_tokens: 16000 + - match: "claude-opus-4-5@20251101" + suffix: ":thinking" + real_name: claude-opus-4-5@20251101 + max_output_tokens: 24000 + supports_function_calling: false + reasoning_levels: [] + patch: + body: + temperature: null + top_p: null + thinking: + type: enabled + budget_tokens: 16000 + - match: "claude-sonnet-4-5@20250929" + suffix: ":thinking" + real_name: claude-sonnet-4-5@20250929 + max_output_tokens: 24000 + supports_function_calling: false + reasoning_levels: [] + patch: + body: + temperature: null + top_p: null + thinking: + type: enabled + budget_tokens: 16000 + - match: "claude-haiku-4-5@20251001" + suffix: ":thinking" + real_name: claude-haiku-4-5@20251001 + max_output_tokens: 24000 + supports_function_calling: false + reasoning_levels: [] + patch: + body: + temperature: null + top_p: null + thinking: + type: enabled + budget_tokens: 16000 + models: + - name: text-embedding-005 + type: embedding + max_input_tokens: 20000 + input_price: 0.025 + max_tokens_per_chunk: 2048 + default_chunk_size: 1500 + max_batch_size: 5 + - name: text-multilingual-embedding-002 + type: embedding + max_input_tokens: 20000 + input_price: 0.2 + max_tokens_per_chunk: 2048 + default_chunk_size: 1500 + max_batch_size: 5 + +- provider: bedrock + rules: + - match: "us.anthropic.claude-*" + require_max_tokens: true + - match: "us.meta.llama*" + require_max_tokens: true + - match: "us.anthropic.claude-opus-5" + default_reasoning_effort: high + - match: "us.anthropic.claude-fable-*" + default_reasoning_effort: high + - match: "us.anthropic.claude-opus-4-8" + default_reasoning_effort: high + - match: "us.anthropic.claude-opus-4-7" + default_reasoning_effort: high + - match: "us.anthropic.claude-opus-4-6-v1" + default_reasoning_effort: high + - match: "us.anthropic.claude-sonnet-5" + default_reasoning_effort: high + - match: "us.anthropic.claude-sonnet-4-6" + default_reasoning_effort: high + variants: + - match: "us.anthropic.claude-opus-4-6-v1" + suffix: ":thinking" + real_name: us.anthropic.claude-opus-4-6-v1 + max_output_tokens: 24000 + supports_function_calling: false + reasoning_levels: [] + patch: + body: + inferenceConfig: + temperature: null + topP: null + additionalModelRequestFields: + thinking: + type: enabled + budget_tokens: 16000 + - match: "us.anthropic.claude-sonnet-4-6" + suffix: ":thinking" + real_name: us.anthropic.claude-sonnet-4-6 + max_output_tokens: 24000 + supports_function_calling: false + reasoning_levels: [] + patch: + body: + inferenceConfig: + temperature: null + topP: null + additionalModelRequestFields: + thinking: + type: enabled + budget_tokens: 16000 + - match: "us.anthropic.claude-opus-4-5-20251101-v1:0" + suffix: ":thinking" + real_name: us.anthropic.claude-opus-4-5-20251101-v1:0 + max_output_tokens: 24000 + supports_function_calling: false + reasoning_levels: [] + patch: + body: + inferenceConfig: + temperature: null + topP: null + additionalModelRequestFields: + thinking: + type: enabled + budget_tokens: 16000 + - match: "us.anthropic.claude-sonnet-4-5-20250929-v1:0" + suffix: ":thinking" + real_name: us.anthropic.claude-sonnet-4-5-20250929-v1:0 + max_output_tokens: 24000 + supports_function_calling: false + reasoning_levels: [] + patch: + body: + inferenceConfig: + temperature: null + topP: null + additionalModelRequestFields: + thinking: + type: enabled + budget_tokens: 16000 + - match: "us.anthropic.claude-haiku-4-5-20251001-v1:0" + suffix: ":thinking" + real_name: us.anthropic.claude-haiku-4-5-20251001-v1:0 + max_output_tokens: 24000 + supports_function_calling: false + reasoning_levels: [] + patch: + body: + inferenceConfig: + temperature: null + topP: null + additionalModelRequestFields: + thinking: + type: enabled + budget_tokens: 16000 + models: + - name: cohere.embed-english-v3 + type: embedding + input_price: 0.1 + max_tokens_per_chunk: 512 + default_chunk_size: 1000 + max_batch_size: 96 + - name: cohere.embed-multilingual-v3 + type: embedding + input_price: 0.1 + max_tokens_per_chunk: 512 + default_chunk_size: 1000 + max_batch_size: 96 + +- provider: cloudflare + rules: + - match: "@cf/*" + require_max_tokens: true + models: + - name: '@cf/baai/bge-large-en-v1.5' + type: embedding + input_price: 0 + max_tokens_per_chunk: 512 + default_chunk_size: 1000 + max_batch_size: 100 + +- provider: ernie + models: + - name: ernie-4.5-turbo-128k + max_input_tokens: 131072 + input_price: 0.112 + output_price: 0.448 + - name: ernie-4.5-turbo-vl-32k + max_input_tokens: 32768 + input_price: 0.42 + output_price: 1.26 + supports_vision: true + - name: ernie-5.0-thinking-preview + max_input_tokens: 131072 + input_price: 1.4 + output_price: 5.6 + - name: ernie-x1.1-preview + max_input_tokens: 65536 + input_price: 0.14 + output_price: 0.56 + - name: bge-large-zh + type: embedding + input_price: 0.07 + max_tokens_per_chunk: 512 + default_chunk_size: 1000 + max_batch_size: 16 + - name: bge-large-en + type: embedding + input_price: 0.07 + max_tokens_per_chunk: 512 + default_chunk_size: 1000 + max_batch_size: 16 + - name: bce-reranker-base + type: reranker + max_input_tokens: 1024 + input_price: 0.07 + +- provider: qianwen + rules: + - match: "qwen3.5-plus" + patch: + body: + enable_thinking: false + - match: "qwen3.5-397b-a17b" + patch: + body: + enable_thinking: false + variants: + - match: "qwen3-max" + suffix: ":thinking" + real_name: qwen3-max + patch: + body: + enable_thinking: true + models: + - name: qwen3.5-plus:thinking + real_name: qwen3.5-plus + max_input_tokens: 262144 + supports_function_calling: true + - name: qwen3.5-397b-a17b:thinking + real_name: qwen3.5-397b-a17b + max_input_tokens: 262144 + supports_function_calling: true + - name: text-embedding-v4 + type: embedding + input_price: 0.1 + max_tokens_per_chunk: 8192 + default_chunk_size: 2000 + max_batch_size: 10 + - name: text-embedding-v3 + type: embedding + input_price: 0.1 + max_tokens_per_chunk: 8192 + default_chunk_size: 2000 + max_batch_size: 10 + +- provider: hunyuan + models: + - name: hunyuan-2.0-instruct-20251111 + max_input_tokens: 131072 + input_price: 0.112 + output_price: 0.28 + supports_function_calling: true + - name: hunyuan-2.0-thinking-20251109 + max_input_tokens: 131072 + input_price: 0.14 + output_price: 0.56 + supports_function_calling: true + - name: hunyuan-vision-1.5-instruct + max_input_tokens: 24576 + input_price: 0.42 + output_price: 1.26 + supports_vision: true + - name: hunyuan-embedding + type: embedding + input_price: 0.01 + max_tokens_per_chunk: 1024 + default_chunk_size: 1000 + max_batch_size: 100 + +- provider: zhipuai + variants: + - match: "glm-5" + suffix: ":instruct" + real_name: glm-5 + patch: + body: + thinking: + type: disabled + - match: "glm-4.7" + suffix: ":instruct" + real_name: glm-4.7 + patch: + body: + thinking: + type: disabled + models: + - name: embedding-3 + type: embedding + max_input_tokens: 8192 + input_price: 0.07 + max_tokens_per_chunk: 8192 + default_chunk_size: 2000 + - name: rerank + type: reranker + max_input_tokens: 4096 + input_price: 0.112 + +- provider: openrouter + rules: + - match: "anthropic/claude-*" + require_max_tokens: true + - match: "openai/gpt-5.6-*" + default_reasoning_effort: medium + - match: "openai/gpt-5.5" + default_reasoning_effort: medium + - match: "openai/gpt-5.5-pro" + default_reasoning_effort: high + - match: "openai/gpt-5.4" + default_reasoning_effort: none + - match: "openai/gpt-5.4-pro" + default_reasoning_effort: medium + - match: "openai/gpt-5.4-mini" + default_reasoning_effort: none + - match: "openai/gpt-5.4-nano" + default_reasoning_effort: none + - match: "openai/gpt-5.3-codex" + default_reasoning_effort: medium + - match: "openai/gpt-5.2" + default_reasoning_effort: none + - match: "openai/gpt-5.2-pro" + default_reasoning_effort: medium + - match: "openai/gpt-5" + default_reasoning_effort: medium + - match: "google/gemini-3.5-flash" + default_reasoning_effort: medium + - match: "google/gemini-3-flash-preview" + default_reasoning_effort: high + - match: "google/gemini-3.1-flash-lite" + default_reasoning_effort: minimal + - match: "google/gemini-3.1-pro-preview" + default_reasoning_effort: high + - match: "google/gemini-3-pro-preview" + default_reasoning_effort: high + - match: "google/gemini-2.5-flash" + default_reasoning_effort: low + - match: "google/gemini-2.5-pro" + default_reasoning_effort: high + - match: "anthropic/claude-opus-5" + default_reasoning_effort: high + - match: "anthropic/claude-fable-*" + default_reasoning_effort: high + - match: "anthropic/claude-opus-4-8" + default_reasoning_effort: high + - match: "anthropic/claude-opus-4-7" + default_reasoning_effort: high + - match: "anthropic/claude-opus-4.6" + default_reasoning_effort: high + - match: "anthropic/claude-sonnet-5" + default_reasoning_effort: high + - match: "anthropic/claude-sonnet-4.6" + default_reasoning_effort: high + - match: "perplexity/sonar-reasoning-pro" + patch: + body: + include_reasoning: true + - match: "perplexity/sonar-deep-research" + patch: + body: + include_reasoning: true + +- provider: github + models: + - name: text-embedding-3-large + type: embedding + max_tokens_per_chunk: 8191 + default_chunk_size: 2000 + max_batch_size: 100 + - name: text-embedding-3-small + type: embedding + max_tokens_per_chunk: 8191 + default_chunk_size: 2000 + max_batch_size: 100 + - name: cohere-embed-v3-english + type: embedding + max_tokens_per_chunk: 512 + default_chunk_size: 1000 + max_batch_size: 96 + - name: cohere-embed-v3-multilingual + type: embedding + max_tokens_per_chunk: 512 + default_chunk_size: 1000 + max_batch_size: 96 + +- provider: deepinfra + models: + - name: BAAI/bge-large-en-v1.5 + type: embedding + input_price: 0.01 + max_tokens_per_chunk: 512 + default_chunk_size: 1000 + max_batch_size: 100 + - name: BAAI/bge-m3 + type: embedding + input_price: 0.01 + max_tokens_per_chunk: 8192 + default_chunk_size: 2000 + max_batch_size: 100 + - name: intfloat/e5-large-v2 + type: embedding + input_price: 0.01 + max_tokens_per_chunk: 512 + default_chunk_size: 1000 + max_batch_size: 100 + - name: intfloat/multilingual-e5-large + type: embedding + input_price: 0.01 + max_tokens_per_chunk: 512 + default_chunk_size: 1000 + max_batch_size: 100 + - name: thenlper/gte-large + type: embedding + input_price: 0.01 + max_tokens_per_chunk: 512 + default_chunk_size: 1000 + max_batch_size: 100 + +- provider: jina + models: + - name: jina-embeddings-v3 + type: embedding + input_price: 0 + max_tokens_per_chunk: 8192 + default_chunk_size: 2000 + max_batch_size: 100 + - name: jina-clip-v2 + type: embedding + input_price: 0 + max_tokens_per_chunk: 8192 + default_chunk_size: 1500 + max_batch_size: 100 + - name: jina-colbert-v2 + type: embedding + input_price: 0 + max_tokens_per_chunk: 8192 + default_chunk_size: 1500 + max_batch_size: 100 + - name: jina-reranker-v2-base-multilingual + type: reranker + max_input_tokens: 8192 + input_price: 0 + - name: jina-colbert-v2 + type: reranker + max_input_tokens: 8192 + input_price: 0 + +- provider: voyageai + models: + - name: voyage-3-large + type: embedding + max_input_tokens: 120000 + input_price: 0.18 + max_tokens_per_chunk: 32000 + default_chunk_size: 2000 + max_batch_size: 128 + - name: voyage-3 + type: embedding + max_input_tokens: 320000 + input_price: 0.06 + max_tokens_per_chunk: 32000 + default_chunk_size: 2000 + max_batch_size: 128 + - name: voyage-3-lite + type: embedding + max_input_tokens: 1000000 + input_price: 0.02 + max_tokens_per_chunk: 32000 + default_chunk_size: 1000 + max_batch_size: 128 + - name: rerank-2 + type: reranker + max_input_tokens: 16000 + input_price: 0.05 + - name: rerank-2-lite + type: reranker + max_input_tokens: 8000 + input_price: 0.02 diff --git a/src/client/catalog.rs b/src/client/catalog.rs new file mode 100644 index 0000000..27e0cda --- /dev/null +++ b/src/client/catalog.rs @@ -0,0 +1,327 @@ +use super::model::ModelData; +use super::oauth::OAuthConfig; + +use fancy_regex::Regex; +use log::warn; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::sync::LazyLock; + +pub const QUIRKS_YAML: &str = include_str!("../../quirks.yaml"); + +pub static ALL_QUIRKS: LazyLock> = + LazyLock::new(|| serde_yaml::from_str(QUIRKS_YAML).expect("quirks.yaml must be valid")); + +/// Hand-owned catalog overrides for a single provider: oauth configuration, +/// curation excludes, field overlays for API-sourced models, suffixed model +/// variants, and fully hand-owned model entries. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ProviderQuirks { + pub provider: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub oauth: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub exclude: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub rules: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub variants: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub models: Vec, +} + +/// A field overlay applied to every API-sourced model whose name matches the +/// glob in `match` (`*` = any run of characters, `?` = exactly one). Only +/// explicitly present fields are written; absent fields leave the model +/// untouched. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct QuirkRule { + pub r#match: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub real_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_input_tokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_output_tokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub input_price: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub output_price: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub patch: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub require_max_tokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub supports_vision: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub supports_function_calling: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub reasoning_levels: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub default_reasoning_effort: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub no_stream: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub no_system_message: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub system_prompt_prefix: Option, +} + +/// Clones every model whose name matches the glob in `match`, appends `suffix` +/// to the clone's name, and overlays any explicitly present fields onto the +/// clone. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct QuirkVariant { + pub r#match: String, + pub suffix: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub real_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_input_tokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_output_tokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub input_price: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub output_price: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub patch: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub require_max_tokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub supports_vision: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub supports_function_calling: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub reasoning_levels: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub default_reasoning_effort: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub no_stream: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub no_system_message: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub system_prompt_prefix: Option, +} + +/// Applies every rule whose glob matches `data.name`, in file order; later +/// rules overwrite earlier ones on the same field. +#[allow(dead_code)] +pub fn apply_rules(provider_quirks: &ProviderQuirks, data: &mut ModelData) { + for rule in &provider_quirks.rules { + if !glob_matches(&rule.r#match, &data.name) { + continue; + } + if let Some(real_name) = &rule.real_name { + data.real_name = Some(real_name.clone()); + } + if let Some(max_input_tokens) = rule.max_input_tokens { + data.max_input_tokens = Some(max_input_tokens); + } + if let Some(max_output_tokens) = rule.max_output_tokens { + data.max_output_tokens = Some(max_output_tokens); + } + if let Some(input_price) = rule.input_price { + data.input_price = Some(input_price); + } + if let Some(output_price) = rule.output_price { + data.output_price = Some(output_price); + } + if let Some(patch) = &rule.patch { + data.patch = Some(patch.clone()); + } + if let Some(require_max_tokens) = rule.require_max_tokens { + data.require_max_tokens = require_max_tokens; + } + if let Some(supports_vision) = rule.supports_vision { + data.supports_vision = supports_vision; + } + if let Some(supports_function_calling) = rule.supports_function_calling { + data.supports_function_calling = supports_function_calling; + } + if let Some(reasoning_levels) = &rule.reasoning_levels { + data.reasoning_levels = reasoning_levels.clone(); + } + if let Some(default_reasoning_effort) = &rule.default_reasoning_effort { + data.default_reasoning_effort = Some(default_reasoning_effort.clone()); + } + if let Some(no_stream) = rule.no_stream { + data.no_stream = no_stream; + } + if let Some(no_system_message) = rule.no_system_message { + data.no_system_message = no_system_message; + } + if let Some(system_prompt_prefix) = &rule.system_prompt_prefix { + data.system_prompt_prefix = Some(system_prompt_prefix.clone()); + } + } +} + +/// Finds the quirks for a client: an exact provider match wins, otherwise a +/// client whose name starts with the provider name (openai-compatible clients +/// are conventionally named after the provider they wrap). +#[allow(dead_code)] +pub fn quirks_for(client_name: &str) -> Option<&'static ProviderQuirks> { + ALL_QUIRKS + .iter() + .find(|quirks| quirks.provider == client_name) + .or_else(|| { + ALL_QUIRKS + .iter() + .find(|quirks| client_name.starts_with(&quirks.provider)) + }) +} + +fn glob_matches(pattern: &str, name: &str) -> bool { + compile_glob(pattern).is_match(name).unwrap_or(false) +} + +/// Translates a glob pattern (`*` = any run of characters, `?` = exactly one) +/// into an anchored regex. Patterns that fail to compile match nothing. +fn compile_glob(pattern: &str) -> Regex { + let translated = format!( + "^{}$", + fancy_regex::escape(pattern) + .replace("\\*", ".*") + .replace("\\?", ".") + ); + Regex::new(&translated).unwrap_or_else(|error| { + warn!("Invalid model quirk pattern '{pattern}': {error}. It will match nothing."); + Regex::new("(?!)").expect("'(?!)' is a valid never-matching regex") + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + const KNOWN_PROVIDERS: [&str; 24] = [ + "openai", + "gemini", + "claude", + "mistral", + "ai21", + "cohere", + "xai", + "perplexity", + "groq", + "vertexai", + "bedrock", + "cloudflare", + "ernie", + "qianwen", + "hunyuan", + "moonshot", + "deepseek", + "zhipuai", + "minimax", + "openrouter", + "github", + "deepinfra", + "jina", + "voyageai", + ]; + + #[test] + fn quirks_yaml_parses() { + assert!(!ALL_QUIRKS.is_empty()); + for quirks in ALL_QUIRKS.iter() { + assert!( + KNOWN_PROVIDERS.contains(&quirks.provider.as_str()), + "unknown provider '{}' in quirks.yaml", + quirks.provider + ); + } + } + + #[test] + fn quirks_models_no_dropped_keys() { + let skipped_when_default = [ + "type", + "require_max_tokens", + "supports_vision", + "supports_function_calling", + "reasoning_levels", + "no_stream", + "no_system_message", + ]; + let raw: serde_yaml::Value = serde_yaml::from_str(QUIRKS_YAML).unwrap(); + for provider in raw.as_sequence().unwrap() { + let Some(models) = provider + .get("models") + .and_then(|models| models.as_sequence()) + else { + continue; + }; + for entry in models { + let parsed: ModelData = serde_yaml::from_value(entry.clone()).unwrap(); + let reserialized = serde_yaml::to_value(&parsed).unwrap(); + let reserialized_keys: Vec<&str> = reserialized + .as_mapping() + .unwrap() + .keys() + .map(|key| key.as_str().unwrap()) + .collect(); + for key in entry.as_mapping().unwrap().keys() { + let key = key.as_str().unwrap(); + assert!( + reserialized_keys.contains(&key) || skipped_when_default.contains(&key), + "key '{key}' on model '{}' was dropped by ModelData", + parsed.name + ); + } + } + } + } + + #[test] + fn rules_apply_in_order() { + let quirks: ProviderQuirks = serde_yaml::from_str( + r#" +provider: openai +rules: + - match: "model-*" + default_reasoning_effort: low + require_max_tokens: true + - match: "model-a" + default_reasoning_effort: high +"#, + ) + .unwrap(); + + let mut matched_by_both = ModelData::new("model-a"); + apply_rules(&quirks, &mut matched_by_both); + assert_eq!( + matched_by_both.default_reasoning_effort.as_deref(), + Some("high") + ); + assert!(matched_by_both.require_max_tokens); + + let mut matched_by_glob = ModelData::new("model-b"); + apply_rules(&quirks, &mut matched_by_glob); + assert_eq!( + matched_by_glob.default_reasoning_effort.as_deref(), + Some("low") + ); + + let mut unmatched = ModelData::new("other"); + apply_rules(&quirks, &mut unmatched); + assert_eq!(unmatched.default_reasoning_effort, None); + assert!(!unmatched.require_max_tokens); + } + + #[test] + fn glob_semantics() { + assert!(glob_matches("claude-*", "claude-opus-4-8")); + assert!(!glob_matches("claude-*", "gpt-4")); + assert!(glob_matches("claude-*", "claude-")); + assert!(glob_matches("gpt-?", "gpt-4")); + assert!(!glob_matches("gpt-?", "gpt-45")); + assert!(!glob_matches("gpt-?", "gpt-")); + assert!(glob_matches("o3", "o3")); + assert!(!glob_matches("o3", "o3-mini")); + } +} diff --git a/src/client/mod.rs b/src/client/mod.rs index c82c561..54cd832 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -1,4 +1,5 @@ mod access_token; +pub mod catalog; mod claude_oauth; mod common; mod gemini_oauth; diff --git a/src/client/model.rs b/src/client/model.rs index 0137be9..2c84ec8 100644 --- a/src/client/model.rs +++ b/src/client/model.rs @@ -330,11 +330,11 @@ pub struct ModelData { #[serde(skip_serializing_if = "Option::is_none")] pub default_reasoning_effort: Option, #[serde(default, skip_serializing_if = "std::ops::Not::not")] - no_stream: bool, + pub(crate) no_stream: bool, #[serde(default, skip_serializing_if = "std::ops::Not::not")] - no_system_message: bool, + pub(crate) no_system_message: bool, #[serde(skip_serializing_if = "Option::is_none")] - system_prompt_prefix: Option, + pub(crate) system_prompt_prefix: Option, // embedding-only properties #[serde(skip_serializing_if = "Option::is_none")]