返回 CodeWhale
offering.rs
根目录 / crates / config / src / route / offering.rs
1 //! Provider model offerings (#3084).
2 //!
3 //! A [`ProviderModelOffering`] binds a provider to a canonical model, the
4 //! provider-owned wire id that serves it, and the endpoint key. This is the
5 //! seam that proves the #2608 invariant: the SAME canonical model can be served
6 //! by multiple providers under DIFFERENT wire ids (some aggregator-prefixed),
7 //! and a prefix never implies provider ownership.
8 //!
9 //! Catalog-derived offerings from [`crate::catalog::bundled_catalog_offerings`]
10 //! remain the general bundled source of truth. [`bundled_offerings`] contains
11 //! only transport facts that Models.dev cannot express, such as a single
12 //! provider routing different models over different wire protocols.
13
14 use serde::{Deserialize, Serialize};
15
16 use super::candidate::PricingSku;
17 use super::capabilities::{CapabilityState, RouteCapabilities};
18 use super::ids::{ModelId, ProviderId, WireModelId};
19
20 /// Token limits for one resolved route/offering.
21 ///
22 /// These are optional because hosted catalogs, local runtimes, and custom
23 /// endpoints can legitimately omit some or all limit facts. Callers should
24 /// treat `None` as unknown, not zero.
25 #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
26 pub struct RouteLimits {
27 /// Total context window (input + output), in tokens.
28 #[serde(default, skip_serializing_if = "Option::is_none")]
29 pub context_tokens: Option<u64>,
30 /// Input-token limit, when the provider reports it separately.
31 #[serde(default, skip_serializing_if = "Option::is_none")]
32 pub input_tokens: Option<u64>,
33 /// Output-token cap for the route/offering, when known.
34 #[serde(default, skip_serializing_if = "Option::is_none")]
35 pub output_tokens: Option<u64>,
36 }
37
38 impl RouteLimits {
39 /// Whether at least one limit fact is known.
40 #[must_use]
41 pub const fn has_known_limit(self) -> bool {
42 self.context_tokens.is_some() || self.input_tokens.is_some() || self.output_tokens.is_some()
43 }
44 }
45
46 /// One provider's way of serving a (possibly canonical) model.
47 ///
48 /// `Eq` is intentionally NOT derived: [`PricingSku::Token`] carries `f64` rates,
49 /// so the offering is only `PartialEq`. No caller keys a set/map on offerings.
50 #[derive(Debug, Clone, PartialEq)]
51 pub struct ProviderModelOffering {
52 /// Provider serving this offering.
53 pub provider: ProviderId,
54 /// Canonical model identity, if this offering maps to one.
55 pub canonical_model: Option<ModelId>,
56 /// Provider-owned wire id sent on the request (verbatim).
57 pub wire_model_id: WireModelId,
58 /// Endpoint key the offering is served on.
59 pub endpoint_key: String,
60 /// Whether this is the provider's default offering.
61 pub default_for_provider: bool,
62 /// Provider/offering-scoped token limits, when known.
63 pub limits: RouteLimits,
64 /// Provider/model-scoped capability facts. Unknown is preserved rather
65 /// than inferred from the wire protocol.
66 pub capabilities: RouteCapabilities,
67 /// Coarse route-facing pricing meter for this offering (#3085).
68 ///
69 /// Projected from the offering's sourced cost at the layer that owns it
70 /// (`CatalogOffering::to_offering` → [`crate::pricing::route_pricing_sku`]).
71 /// The resolver carries this verbatim onto the candidate; it is
72 /// [`PricingSku::UnknownOrStale`] whenever no price was sourced — never a
73 /// fabricated zero (the #2608 / #3085 honesty rule).
74 pub pricing: PricingSku,
75 }
76
77 // Transport snapshot verified against https://opencode.ai/docs/zen on
78 // 2026-07-17. Gemini rows are intentionally absent because they use Google's
79 // model-specific wire protocol, which CodeWhale does not currently implement.
80 /// Token Plan text models (Text Generation / Reasoning, coding scope).
81 ///
82 /// Available on both Token Plan Personal and Team. The same model set is also
83 /// available on the Coding Plan; rows are duplicated per provider id below.
84 /// Pay-as-you-go workspace-id templating is deferred to a follow-up.
85 const MODELSTUDIO_TEXT_MODELS: &[&str] = &[
86 "qwen3.8-max",
87 "qwen3.8-max-preview",
88 "qwen3.7-plus",
89 "qwen3.7-max",
90 "qwen3.6-flash",
91 // DeepSeek models served under Model Studio are scoped to this provider;
92 // they do not collide with first-party DeepSeek routes.
93 "deepseek-v4-pro",
94 "deepseek-v4-flash-0731",
95 // GLM models served under Model Studio are scoped to this provider;
96 // they do not collide with first-party Zhipu / Z.ai routes.
97 //
98 // glm-5.3 is deliberately absent (2026-08-03): this list is a curated
99 // snapshot of what Model Studio's upstream roster actually serves, and
100 // Model Studio publishes no glm-5.3 entry. The direct Z.ai / OpenRouter
101 // glm-5.3 rows inherit their metadata from glm-5.2, but metadata
102 // inheritance is not evidence that a third-party gateway carries the
103 // model. Add it here only against a Model Studio console/roster listing.
104 "glm-5.2",
105 ];
106
107 pub(crate) const OPENCODE_ZEN_RESPONSES_MODELS: &[&str] = &[
108 "gpt-5.6-sol",
109 "gpt-5.6-terra",
110 "gpt-5.6-luna",
111 "gpt-5.5",
112 "gpt-5.5-pro",
113 "gpt-5.4",
114 "gpt-5.4-pro",
115 "gpt-5.4-mini",
116 "gpt-5.4-nano",
117 "gpt-5.3-codex",
118 "gpt-5.3-codex-spark",
119 "gpt-5.2",
120 "gpt-5.2-codex",
121 "gpt-5.1",
122 "gpt-5.1-codex",
123 "gpt-5.1-codex-max",
124 "gpt-5.1-codex-mini",
125 "gpt-5",
126 "gpt-5-codex",
127 "gpt-5-nano",
128 ];
129
130 pub(crate) const OPENCODE_ZEN_MESSAGES_MODELS: &[&str] = &[
131 "claude-fable-5",
132 "claude-opus-4-8",
133 "claude-opus-4-7",
134 "claude-opus-4-6",
135 "claude-opus-4-5",
136 "claude-sonnet-5",
137 "claude-sonnet-4-6",
138 "claude-sonnet-4-5",
139 "claude-haiku-4-5",
140 "qwen3.7-max",
141 "qwen3.7-plus",
142 "qwen3.6-plus",
143 "qwen3.5-plus",
144 ];
145
146 pub(crate) const OPENCODE_ZEN_CHAT_MODELS: &[&str] = &[
147 "deepseek-v4-pro",
148 "deepseek-v4-flash",
149 "minimax-m3",
150 "minimax-m2.7",
151 "minimax-m2.5",
152 // glm-5.3 is deliberately absent (2026-08-03): this snapshot tracks the
153 // official OpenCode Zen endpoint table, which lists no glm-5.3 row. Zen
154 // fails closed on unknown models by design; registering a route Zen does
155 // not serve would convert that into a guaranteed upstream 404.
156 "glm-5.2",
157 "glm-5.1",
158 "glm-5",
159 "kimi-k2.5",
160 "kimi-k2.6",
161 "kimi-k2.7-code",
162 "grok-4.5",
163 "grok-build-0.1",
164 "big-pickle",
165 "mimo-v2.5-free",
166 "north-mini-code-free",
167 "nemotron-3-ultra-free",
168 "deepseek-v4-flash-free",
169 ];
170
171 /// Return curated provider/model transport facts as owned offering rows.
172 ///
173 /// OpenCode Zen's official catalog serves models over three protocol families.
174 /// These rows intentionally carry no inferred limits, pricing, or canonical
175 /// identity: their sole claim is the documented wire model and endpoint key.
176 #[must_use]
177 pub fn bundled_offerings() -> Vec<ProviderModelOffering> {
178 // DeepSeek's 2026-07-31 production Flash update added a native Responses
179 // endpoint without changing the model id. Pro remains Chat Completions
180 // until its announced Responses rollout. These exact-route transport facts
181 // cannot be represented by the Models.dev-shaped fallback asset.
182 let deepseek = ProviderId::from("deepseek");
183 let documented_capabilities = RouteCapabilities {
184 image_input: CapabilityState::Unsupported,
185 reasoning: CapabilityState::Supported,
186 native_tool_calls: CapabilityState::Supported,
187 structured_output: CapabilityState::Supported,
188 parallel_tool_calls: CapabilityState::Supported,
189 streaming: CapabilityState::Supported,
190 prompt_caching: CapabilityState::Supported,
191 // The endpoint supports native web search, but Codewhale does not yet
192 // replay `web_search_call` items on this stateless route. Keep the
193 // executable capability honest until that loop is implemented.
194 server_side_web_search: CapabilityState::Unknown,
195 ..RouteCapabilities::default()
196 };
197 let documented_limits = RouteLimits {
198 context_tokens: Some(1_000_000),
199 input_tokens: None,
200 output_tokens: Some(384_000),
201 };
202 let mut offerings = vec![
203 ProviderModelOffering {
204 provider: deepseek.clone(),
205 canonical_model: Some(ModelId::from("deepseek-v4-pro")),
206 wire_model_id: WireModelId::from("deepseek-v4-pro"),
207 endpoint_key: "chat".to_string(),
208 default_for_provider: true,
209 limits: documented_limits,
210 capabilities: documented_capabilities,
211 pricing: PricingSku::UnknownOrStale,
212 },
213 ProviderModelOffering {
214 provider: deepseek,
215 canonical_model: Some(ModelId::from("deepseek-v4-flash")),
216 wire_model_id: WireModelId::from("deepseek-v4-flash"),
217 endpoint_key: "responses".to_string(),
218 default_for_provider: false,
219 limits: documented_limits,
220 capabilities: documented_capabilities,
221 pricing: PricingSku::UnknownOrStale,
222 },
223 ];
224
225 let provider = ProviderId::from("opencode-zen");
226 let groups = [
227 ("responses", OPENCODE_ZEN_RESPONSES_MODELS),
228 ("messages", OPENCODE_ZEN_MESSAGES_MODELS),
229 ("chat", OPENCODE_ZEN_CHAT_MODELS),
230 ];
231
232 offerings.extend(groups.into_iter().flat_map(|(endpoint_key, models)| {
233 let provider = provider.clone();
234 models.iter().map(move |model| ProviderModelOffering {
235 provider: provider.clone(),
236 // The bundled catalog exposes `gpt-5.6` as the user-facing
237 // logical choice and records `gpt-5.6-sol` as its proven Zen
238 // wire id. Keep the generic choice honest by resolving it to the
239 // documented concrete Responses model rather than sending an
240 // unproven generic wire id to Zen.
241 canonical_model: (*model == "gpt-5.6-sol").then(|| ModelId::from("gpt-5.6")),
242 wire_model_id: WireModelId::from(*model),
243 endpoint_key: endpoint_key.to_string(),
244 default_for_provider: *model == "gpt-5.6-sol",
245 limits: RouteLimits::default(),
246 capabilities: RouteCapabilities::default(),
247 pricing: PricingSku::UnknownOrStale,
248 })
249 }));
250
251 // Alibaba Cloud Model Studio — one vendor identity in the hand seam
252 // (`modelstudio-token-plan`). Plan (token vs coding) and wire dialect
253 // (OpenAI Chat Completions vs Anthropic Messages) are config (`mode` /
254 // `wire`), not separate ProviderKinds — same product shape as Z.ai /
255 // Xiaomi for plans and a power-user toggle for dialect. Legacy provider
256 // ids still get catalog rows so old configs resolve, but the picker
257 // catalog surface only lists the primary id.
258 //
259 // Limits: owner's Token Plan console + curated models_dev rows
260 // (2026-08-03): qwen3.8-max is ~1M context / 128K output, NOT 128K
261 // total. Empty RouteLimits here used to win identity collisions over
262 // the asset catalog and fall through to the 128K legacy default.
263 fn ms_capabilities(model: &str) -> RouteCapabilities {
264 let image_input = match model {
265 "qwen3.8-max" | "qwen3.8-max-preview" | "qwen3.7-plus" | "qwen3.6-flash" => {
266 CapabilityState::Supported
267 }
268 _ => CapabilityState::Unsupported,
269 };
270 RouteCapabilities {
271 reasoning: CapabilityState::Supported,
272 native_tool_calls: CapabilityState::Supported,
273 structured_output: CapabilityState::Supported,
274 streaming: CapabilityState::Supported,
275 image_input,
276 ..RouteCapabilities::default()
277 }
278 }
279 fn ms_limits(model: &str) -> RouteLimits {
280 // Context/output from models_dev.bundled.json Model Studio rows and
281 // the owner console (verified 2026-08-03). Keep output separate from
282 // context so a 128K generation ceiling is never mistaken for the
283 // window.
284 let (context_tokens, output_tokens) = match model {
285 "qwen3.8-max" | "qwen3.8-max-preview" => (1_000_000, 131_072),
286 "qwen3.7-plus" | "qwen3.7-max" => (1_000_000, 65_536),
287 "qwen3.6-flash" => (1_000_000, 65_536),
288 "deepseek-v4-pro" | "deepseek-v4-flash-0731" => (1_000_000, 384_000),
289 "glm-5.2" => (1_000_000, 131_072),
290 _ => (1_000_000, 131_072),
291 };
292 RouteLimits {
293 context_tokens: Some(context_tokens),
294 input_tokens: None,
295 output_tokens: Some(output_tokens),
296 }
297 }
298 // Primary vendor id only in the hand seam. Coding-plan / anthropic
299 // dialect endpoint selection is owned by config resolution (mode/wire),
300 // which rewrites base_url + request dialect without inventing kinds.
301 let plan = ProviderId::from("modelstudio-token-plan");
302 offerings.extend(
303 MODELSTUDIO_TEXT_MODELS
304 .iter()
305 .enumerate()
306 .map(|(i, model)| ProviderModelOffering {
307 provider: plan.clone(),
308 canonical_model: None,
309 wire_model_id: WireModelId::from(*model),
310 endpoint_key: "chat".to_string(),
311 default_for_provider: i == 0,
312 limits: ms_limits(model),
313 capabilities: ms_capabilities(model),
314 pricing: PricingSku::UnknownOrStale,
315 }),
316 );
317
318 offerings
319 }
320
320 lines RUST