返回 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 // Muse Spark via OpenCode Zen gateway — Responses-only (reported
129 // 2026-08-29: muse-spark-1.2-contributor-free rejects Chat Completions).
130 "muse-spark-1.2",
131 "muse-spark-1.2-contributor",
132 "muse-spark-1.2-contributor-free",
133 ];
134
135 pub(crate) const OPENCODE_ZEN_MESSAGES_MODELS: &[&str] = &[
136 "claude-fable-5",
137 "claude-opus-4-8",
138 "claude-opus-4-7",
139 "claude-opus-4-6",
140 "claude-opus-4-5",
141 "claude-sonnet-5",
142 "claude-sonnet-4-6",
143 "claude-sonnet-4-5",
144 "claude-haiku-4-5",
145 "qwen3.7-max",
146 "qwen3.7-plus",
147 "qwen3.6-plus",
148 "qwen3.5-plus",
149 ];
150
151 pub(crate) const OPENCODE_ZEN_CHAT_MODELS: &[&str] = &[
152 "deepseek-v4-pro",
153 "deepseek-v4-flash",
154 "minimax-m3",
155 "minimax-m2.7",
156 "minimax-m2.5",
157 // glm-5.3 is deliberately absent (2026-08-03): this snapshot tracks the
158 // official OpenCode Zen endpoint table, which lists no glm-5.3 row. Zen
159 // fails closed on unknown models by design; registering a route Zen does
160 // not serve would convert that into a guaranteed upstream 404.
161 "glm-5.2",
162 "glm-5.1",
163 "glm-5",
164 "kimi-k2.5",
165 "kimi-k2.6",
166 "kimi-k2.7-code",
167 "grok-4.5",
168 "grok-build-0.1",
169 "big-pickle",
170 "mimo-v2.5-free",
171 "north-mini-code-free",
172 "nemotron-3-ultra-free",
173 "deepseek-v4-flash-free",
174 ];
175
176 /// Logical default plus every documented Zen wire id, for picker fallbacks
177 /// when Models.dev is stale or failed. `gpt-5.6` is the user-facing default;
178 /// `gpt-5.6-sol` is the proven Responses wire id.
179 #[must_use]
180 pub fn opencode_zen_picker_models() -> Vec<&'static str> {
181 let mut models = vec![crate::DEFAULT_OPENCODE_ZEN_MODEL];
182 for model in OPENCODE_ZEN_RESPONSES_MODELS
183 .iter()
184 .chain(OPENCODE_ZEN_MESSAGES_MODELS)
185 .chain(OPENCODE_ZEN_CHAT_MODELS)
186 {
187 if !models
188 .iter()
189 .any(|existing| existing.eq_ignore_ascii_case(model))
190 {
191 models.push(*model);
192 }
193 }
194 models
195 }
196
197 /// Codewhale API bootstrap rows used only when the account's live
198 /// `GET {base}/models` cannot be fetched.
199 ///
200 /// The account catalog is authoritative: it lists exactly the providers the
201 /// customer connected, and each row states its own protocol. These three rows
202 /// exist so a route can still be selected offline; every consumer that shows
203 /// models must say the list is a fallback, not the account's catalog.
204 pub const CODEWHALE_FALLBACK_MODELS: &[&str] = &[
205 "deepseek/deepseek-v4-pro",
206 "anthropic/claude-sonnet-5",
207 "openai/gpt-5.6",
208 ];
209
210 /// Endpoint key for one Codewhale API model id.
211 ///
212 /// The live catalog states the protocol per model in `codewhale.protocol`;
213 /// this is the offline inference used for the bootstrap rows and for a model
214 /// id the local catalog has never seen. Only the `anthropic/` namespace routes
215 /// to `{base}/messages`; everything else is OpenAI Chat Completions at
216 /// `{base}/chat/completions`. The id alone carries no signal for the
217 /// Responses surface — a `responses` row only ever comes from the catalog's
218 /// stated `codewhale.protocol`, never from a model name.
219 #[must_use]
220 pub fn codewhale_endpoint_key_for_model(model: &str) -> &'static str {
221 if model.trim().to_ascii_lowercase().starts_with("anthropic/") {
222 "messages"
223 } else {
224 "chat"
225 }
226 }
227
228 /// Return curated provider/model transport facts as owned offering rows.
229 ///
230 /// OpenCode Zen's official catalog serves models over three protocol families.
231 /// These rows intentionally carry no inferred limits, pricing, or canonical
232 /// identity: their sole claim is the documented wire model and endpoint key.
233 #[must_use]
234 pub fn bundled_offerings() -> Vec<ProviderModelOffering> {
235 // DeepSeek's 2026-07-31 production Flash update added a native Responses
236 // endpoint without changing the model id, and the 2026-08 unversioned
237 // rename to `deepseek-flash` kept that wire: DeepSeek's own Codex
238 // integration documents the Responses API as the path for `deepseek-flash`
239 // (legacy `deepseek-v4-flash` ids are served by the same model). The
240 // shipped default therefore rides Responses; Pro remains Chat Completions
241 // until its announced Responses rollout. These exact-route transport facts
242 // cannot be represented by the Models.dev-shaped fallback asset.
243 let deepseek = ProviderId::from("deepseek");
244 let documented_capabilities = RouteCapabilities {
245 image_input: CapabilityState::Unsupported,
246 reasoning: CapabilityState::Supported,
247 native_tool_calls: CapabilityState::Supported,
248 structured_output: CapabilityState::Supported,
249 parallel_tool_calls: CapabilityState::Supported,
250 streaming: CapabilityState::Supported,
251 prompt_caching: CapabilityState::Supported,
252 // Search execution uses a separate bounded Responses request rather
253 // than replaying `web_search_call` items in the main chat.
254 server_side_web_search: CapabilityState::Supported,
255 ..RouteCapabilities::default()
256 };
257 let documented_limits = RouteLimits {
258 context_tokens: Some(1_000_000),
259 input_tokens: None,
260 output_tokens: Some(384_000),
261 };
262 let mut offerings = vec![
263 ProviderModelOffering {
264 provider: deepseek.clone(),
265 canonical_model: Some(ModelId::from("deepseek-flash")),
266 wire_model_id: WireModelId::from("deepseek-flash"),
267 endpoint_key: "responses".to_string(),
268 default_for_provider: true,
269 limits: documented_limits,
270 capabilities: documented_capabilities,
271 pricing: PricingSku::UnknownOrStale,
272 },
273 ProviderModelOffering {
274 provider: deepseek.clone(),
275 canonical_model: Some(ModelId::from("deepseek-v4-pro")),
276 wire_model_id: WireModelId::from("deepseek-v4-pro"),
277 endpoint_key: "chat".to_string(),
278 default_for_provider: false,
279 limits: documented_limits,
280 capabilities: documented_capabilities,
281 pricing: PricingSku::UnknownOrStale,
282 },
283 ProviderModelOffering {
284 provider: deepseek.clone(),
285 canonical_model: Some(ModelId::from("deepseek-v4-flash")),
286 wire_model_id: WireModelId::from("deepseek-v4-flash"),
287 endpoint_key: "responses".to_string(),
288 default_for_provider: false,
289 limits: documented_limits,
290 capabilities: documented_capabilities,
291 pricing: PricingSku::UnknownOrStale,
292 },
293 // Vision-experimental sibling of v4-flash, verified live on
294 // api.deepseek.com /models (2026-08-21). Image input is the one
295 // documented difference; limits inherit the v4-flash row until
296 // DeepSeek publishes distinct numbers.
297 ProviderModelOffering {
298 provider: deepseek,
299 canonical_model: Some(ModelId::from("deepseek-v4-flash-vision-exp")),
300 wire_model_id: WireModelId::from("deepseek-v4-flash-vision-exp"),
301 endpoint_key: "chat".to_string(),
302 default_for_provider: false,
303 limits: documented_limits,
304 capabilities: RouteCapabilities {
305 image_input: CapabilityState::Supported,
306 ..documented_capabilities
307 },
308 pricing: PricingSku::UnknownOrStale,
309 },
310 ];
311
312 offerings.extend(
313 crate::opencode_go::MODEL_GROUPS
314 .iter()
315 .flat_map(|(endpoint_key, models)| {
316 models.iter().map(move |model| ProviderModelOffering {
317 provider: ProviderId::from("opencode-go"),
318 canonical_model: None,
319 wire_model_id: WireModelId::from(*model),
320 endpoint_key: (*endpoint_key).to_string(),
321 default_for_provider: *model == crate::DEFAULT_OPENCODE_GO_MODEL,
322 limits: RouteLimits::default(),
323 capabilities: RouteCapabilities::default(),
324 pricing: PricingSku::UnknownOrStale,
325 })
326 }),
327 );
328
329 let provider = ProviderId::from("opencode-zen");
330 let groups = [
331 ("responses", OPENCODE_ZEN_RESPONSES_MODELS),
332 ("messages", OPENCODE_ZEN_MESSAGES_MODELS),
333 ("chat", OPENCODE_ZEN_CHAT_MODELS),
334 ];
335
336 offerings.extend(groups.into_iter().flat_map(|(endpoint_key, models)| {
337 let provider = provider.clone();
338 models.iter().map(move |model| ProviderModelOffering {
339 provider: provider.clone(),
340 // The bundled catalog exposes `gpt-5.6` as the user-facing
341 // logical choice and records `gpt-5.6-sol` as its proven Zen
342 // wire id. Keep the generic choice honest by resolving it to the
343 // documented concrete Responses model rather than sending an
344 // unproven generic wire id to Zen.
345 canonical_model: (*model == "gpt-5.6-sol").then(|| ModelId::from("gpt-5.6")),
346 wire_model_id: WireModelId::from(*model),
347 endpoint_key: endpoint_key.to_string(),
348 default_for_provider: *model == "gpt-5.6-sol",
349 limits: RouteLimits::default(),
350 capabilities: RouteCapabilities::default(),
351 pricing: PricingSku::UnknownOrStale,
352 })
353 }));
354
355 // Codewhale API bootstrap rows. The account's authenticated
356 // `GET {base}/models` is the catalog authority and replaces these as soon
357 // as it is reachable; they exist so the route resolves offline.
358 let codewhale = ProviderId::from("codewhale");
359 offerings.extend(
360 CODEWHALE_FALLBACK_MODELS
361 .iter()
362 .map(|model| ProviderModelOffering {
363 provider: codewhale.clone(),
364 canonical_model: None,
365 wire_model_id: WireModelId::from(*model),
366 endpoint_key: codewhale_endpoint_key_for_model(model).to_string(),
367 default_for_provider: *model == crate::DEFAULT_CODEWHALE_MODEL,
368 limits: RouteLimits::default(),
369 capabilities: RouteCapabilities::default(),
370 pricing: PricingSku::UnknownOrStale,
371 }),
372 );
373
374 // Alibaba Cloud Model Studio — one vendor identity in the hand seam
375 // (`modelstudio-token-plan`). Plan (token vs coding) and wire dialect
376 // (OpenAI Chat Completions vs Anthropic Messages) are config (`mode` /
377 // `wire`), not separate ProviderKinds — same product shape as Z.ai /
378 // Xiaomi for plans and a power-user toggle for dialect. Legacy provider
379 // ids still get catalog rows so old configs resolve, but the picker
380 // catalog surface only lists the primary id.
381 //
382 // Limits: owner's Token Plan console + curated models_dev rows
383 // (2026-08-03): qwen3.8-max is ~1M context / 128K output, NOT 128K
384 // total. Empty RouteLimits here used to win identity collisions over
385 // the asset catalog and fall through to the 128K legacy default.
386 fn ms_capabilities(model: &str) -> RouteCapabilities {
387 let image_input = match model {
388 "qwen3.8-max" | "qwen3.8-max-preview" | "qwen3.7-plus" | "qwen3.6-flash" => {
389 CapabilityState::Supported
390 }
391 _ => CapabilityState::Unsupported,
392 };
393 RouteCapabilities {
394 reasoning: CapabilityState::Supported,
395 native_tool_calls: CapabilityState::Supported,
396 structured_output: CapabilityState::Supported,
397 streaming: CapabilityState::Supported,
398 image_input,
399 server_side_web_search: super::documented_server_side_web_search(
400 "modelstudio-token-plan",
401 model,
402 ),
403 ..RouteCapabilities::default()
404 }
405 }
406 fn ms_limits(model: &str) -> RouteLimits {
407 // Context/output from models_dev.bundled.json Model Studio rows and
408 // the owner console (verified 2026-08-03). Keep output separate from
409 // context so a 128K generation ceiling is never mistaken for the
410 // window.
411 let (context_tokens, output_tokens) = match model {
412 "qwen3.8-max" | "qwen3.8-max-preview" => (1_000_000, 131_072),
413 "qwen3.7-plus" | "qwen3.7-max" => (1_000_000, 65_536),
414 "qwen3.6-flash" => (1_000_000, 65_536),
415 "deepseek-v4-pro" | "deepseek-v4-flash-0731" => (1_000_000, 384_000),
416 "glm-5.2" => (1_000_000, 131_072),
417 _ => (1_000_000, 131_072),
418 };
419 RouteLimits {
420 context_tokens: Some(context_tokens),
421 input_tokens: None,
422 output_tokens: Some(output_tokens),
423 }
424 }
425 // Primary vendor id only in the hand seam. Coding-plan / anthropic
426 // dialect endpoint selection is owned by config resolution (mode/wire),
427 // which rewrites base_url + request dialect without inventing kinds.
428 let plan = ProviderId::from("modelstudio-token-plan");
429 offerings.extend(
430 MODELSTUDIO_TEXT_MODELS
431 .iter()
432 .enumerate()
433 .map(|(i, model)| ProviderModelOffering {
434 provider: plan.clone(),
435 canonical_model: None,
436 wire_model_id: WireModelId::from(*model),
437 endpoint_key: "chat".to_string(),
438 default_for_provider: i == 0,
439 limits: ms_limits(model),
440 capabilities: ms_capabilities(model),
441 pricing: PricingSku::UnknownOrStale,
442 }),
443 );
444
445 offerings
446 }
447
447 lines RUST