Files
windmill/frontend/src/lib/components/copilot/modelConfig.ts
T

254 lines
12 KiB
TypeScript

import type { AIProvider } from '$lib/gen'
export type ParsedModelId = {
/** Vendor namespace when the id carries one (`anthropic/claude-sonnet-5`). */
vendor: string | undefined
/** Bare model id: no vendor prefix, no variant suffix. */
base: string
}
/**
* Split a model id into the parts the predicates below match on. Gateways decorate
* the vendor's id in ways a raw substring check misses: OpenRouter marks its own
* floating aliases with a `~` prefix (`~anthropic/claude-sonnet-latest`, distinct
* from the vendor-pinned `anthropic/claude-sonnet-5`) and appends `:variant`
* suffixes (`:free`, `:thinking`). Match on the parsed parts, never on the raw id.
*
* `base` is the last `/` segment: the deprecated `<model>/thinking` selection puts
* its marker where a gateway puts the model, so callers that must resolve one of
* those ids first run it through `stripLegacyThinkingSuffix`.
*/
export function parseModelId(model: string): ParsedModelId {
const normalized = model.toLowerCase().replace(/^~/, '')
const segments = normalized.split('/')
const last = segments[segments.length - 1]
const colon = last.indexOf(':')
return {
vendor: segments.length > 1 ? segments[0] : undefined,
base: colon > 0 ? last.slice(0, colon) : last
}
}
// Azure AI Foundry fronts multiple model families under one resource. Claude
// deployments are served only through the Anthropic Messages API, so the chat must
// route them like the native Anthropic provider (Anthropic SDK, message format)
// rather than the OpenAI-compatible surface used for the rest of Foundry's catalog.
// Mirrors the backend `AIProvider::is_anthropic_model`.
export function usesAnthropicMessagesApi(provider: AIProvider, model: string): boolean {
return (
provider === 'anthropic' ||
(provider === 'azure_foundry' && model.toLowerCase().startsWith('claude'))
)
}
// Anthropic bills a cached prefix at a tenth of the input rate, but only creates one
// where an explicit `cache_control` breakpoint sits. With no breakpoint the whole
// prompt is charged in full on every iteration of a chat. The native Anthropic path
// sets its own breakpoints; OpenRouter forwards them over the OpenAI-compatible surface
// but only documents them for Anthropic-backed models, so the gate is on the routed
// model rather than the provider alone.
export function usesOpenRouterPromptCaching(provider: AIProvider, model: string): boolean {
return provider === 'openrouter' && parseModelId(model).vendor === 'anthropic'
}
// gpt-5+ and o-series reasoning models reject the legacy `max_tokens` field on
// the OpenAI/Azure Chat Completions API and require `max_completion_tokens`
// instead. The check runs on the bare model id (so OpenRouter's "openai/o3"
// matches), and the o-series match requires a digit after the "o" (o1/o3/o4-mini)
// so it does not catch unrelated ids like Mistral's "open-mistral-*" or "optimus-*".
export function requiresMaxCompletionTokens(model: string) {
const baseModel = parseModelId(model).base
return baseModel.startsWith('gpt-5') || /^o\d/.test(baseModel)
}
// Context windows of the models we know, most specific entry first — the first
// name found in the bare model id wins, so vendor-namespaced and date-suffixed
// ids (anthropic.claude-sonnet-4-6-...-v1:0, gpt-5.2-2026-01-01) still resolve.
// Conservative family fallbacks sit below the explicit entries; models not
// listed at all resolve to undefined. Consumers that need a number regardless
// (trim/compaction, the usage indicator) go through getModelContextWindow,
// whose conservative 128K fallback keeps a limit enforced and is surfaced to
// the user as an assumed window.
const MODEL_CONTEXT_WINDOWS: [name: string, contextWindow: number][] = [
// Anthropic — Sonnet/Opus 4.6+ ship a 1M window at standard pricing (GA);
// Haiku, older Claude models (3.x, 4.0, 4.1, 4.5) and date-suffixed Claude 4
// base ids (claude-sonnet-4-20250514) fall through to 200K
['claude-fable-5', 1_000_000],
['claude-opus-5', 1_000_000],
['claude-sonnet-5', 1_000_000],
['claude-opus-4-8', 1_000_000],
['claude-opus-4-7', 1_000_000],
['claude-opus-4-6', 1_000_000],
['claude-sonnet-4-6', 1_000_000],
['claude', 200_000],
// OpenAI — gpt-5 covers the base family (-mini / -nano) and the 5.1/5.2
// revisions, all 400K; only 5.4+ moved to 1M
['gpt-5.5', 1_000_000],
['gpt-5.4', 1_000_000],
['gpt-5', 400_000],
['gpt-4.1', 1_000_000],
['gpt-4o', 128_000],
['o4-mini', 200_000],
['o3', 200_000],
// Google — the 2.5 / 3 / 3.1 Gemini families are all 1M
['gemini-3.1', 1_000_000],
['gemini-3', 1_000_000],
['gemini-2.5', 1_000_000],
// DeepSeek — the V4 family is 1M; deepseek-chat / deepseek-reasoner are
// aliases of V4-Flash since April 2026
['deepseek-v4', 1_000_000],
['deepseek-chat', 1_000_000],
['deepseek-reasoner', 1_000_000],
['deepseek', 128_000],
// Alibaba — Qwen3-Max is 256K. No qwen family fallback: variant windows range
// from 8K (character models) to 1M, too wide for even a conservative guess
['qwen3-max', 256_000],
// Others
['llama', 128_000],
['codestral', 32_000]
]
// Version separators differ by route to the same model: Anthropic writes
// `claude-opus-4-8`, OpenRouter writes `anthropic/claude-opus-4.8`. Collapsing
// dots to dashes on both sides keeps one table entry covering every route —
// without it a dot-versioned id falls through to a coarser family entry.
function normalizeVersionSeparators(model: string): string {
return model.replace(/\./g, '-')
}
/** Suffixes that name a route to a model rather than a different model. */
const DECORATIVE_SUFFIXES = ['latest', 'preview', 'beta', 'stable']
/**
* Compile a most-specific-first `[name, value]` table into matchers against the
* bare model id. Shared with the pricing table so both resolve the same set of
* ids — a model whose window is known but whose price is not (or vice versa)
* should be a gap in one table, never a difference in matching.
*
* An entry that ends on a version digit must not run into a longer version:
* `gpt-4.1` collapses to `gpt-4-1`, which would otherwise claim
* `gpt-4-1106-preview`. Suffixes that continue with a separator
* (`claude-opus-4-8` in `...-4-8-v1`, `gpt-5` in `gpt-5-mini`) still match.
* Family fallbacks ending on a letter get no such guard — a version welded
* straight onto the name (`llama3.1`) is exactly what they exist to catch.
*/
export function buildModelMatchers<T>(
entries: [name: string, value: T][],
{ strictVariants = false }: { strictVariants?: boolean } = {}
): [RegExp, T][] {
return entries.map(([name, value]) => {
const pattern = normalizeVersionSeparators(name).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const guards = [
// An entry ending on a version digit must not run into a longer version.
/\d$/.test(pattern) ? '(?!\\d)' : '',
// A named sub-model (`gpt-5-pro`, `gpt-5-mini`) is a different model with
// its own price, not another route to this one — so under strictVariants an
// entry does not match when a further *name* segment follows. What follows
// is only a decoration when it is a date (`-20251101`), Bedrock's `-v1`, or
// one of the alias words below (`claude-3-5-haiku-latest` is the same model
// as `claude-3-5-haiku`, and is a shipped default). Off by default: for a
// context window an inherited value is a safe approximation, for a price it
// is a wrong number.
strictVariants ? `(?!-(?!v\\d|${DECORATIVE_SUFFIXES.join('|')})[a-z])` : ''
].join('')
return [new RegExp(pattern + guards), value]
})
}
/**
* The `provider:model` key the workspace AI settings use for their per-model maps
* (`max_tokens_per_model`, `model_pricing`). A bare model id is not enough: the
* same id can be served by more than one provider at different rates.
*
* Matched exactly, unlike the fuzzy tables above. Those tables generalize across
* every route to one model on purpose; a per-model *setting* must not, or an
* admin could not give two variants of a family different values — and the key is
* built from the exact id the provider config lists, which is the same string the
* chat sends.
*/
export function modelKey(provider: AIProvider | string, model: string): string {
return `${provider}:${model}`
}
export function matchModel<T>(matchers: [RegExp, T][], model: string): T | undefined {
const id = normalizeVersionSeparators(parseModelId(model).base)
return matchers.find(([matcher]) => matcher.test(id))?.[1]
}
const MODEL_CONTEXT_WINDOW_MATCHERS = buildModelMatchers(MODEL_CONTEXT_WINDOWS)
export function getKnownModelContextWindow(model: string): number | undefined {
return matchModel(MODEL_CONTEXT_WINDOW_MATCHERS, model)
}
export function getModelContextWindow(model: string) {
// Trim/compaction logic needs a number; assume a conservative window when unknown.
return getKnownModelContextWindow(model) ?? 128000
}
/**
* Best-effort check that a model can accept image input. There is no per-model vision
* metadata in the codebase, so this is deliberately permissive: it returns true unless
* the model is a known text-only one that would 400 on an image part. Used to gate the
* image-attach affordance and the screenshot follow-up; when unsure it allows the image
* (the user explicitly attached it — better to try than to silently drop it).
*/
export function modelSupportsVision(
provider: AIProvider | undefined,
model: string | undefined
): boolean {
if (!provider) return true
return !TEXT_ONLY_MODELS.has(`${provider}:${(model ?? '').toLowerCase()}`)
}
/**
* Models whose provider API refuses image content, matched by exact
* `provider:model` pair — not by id alone, because an id proves nothing about a
* different endpoint (a Custom AI deployment may serve a vision model under a
* name that collides with someone's text-only id, and there is no override).
*
* The question is not whether a model can see, but whether its provider's API
* accepts image parts — the two diverge, and the divergence is invisible from a
* name: DeepSeek V4 ships vision in its chat UI while its API has no image
* content type, and o3-mini gained vision in ChatGPT that the API never exposed.
* So this is a cache of one provider's API surface at one moment, and it rots.
* Wrong entries are asymmetric: a missing one costs a single turn and
* self-corrects (the request fails, the image is dropped, the user is told),
* while a wrong one blocks a working model with no override. Hence exact pairs
* only, and only where a provider doc says so.
*
* Substrings are specifically avoided: `mistral-large` would also match
* Mistral Large 3, which does take images, and `phi-4` would match
* Phi-4-multimodal, which does too.
*/
const TEXT_ONLY_MODELS = new Set([
'openai:o1-mini',
'openai:o3-mini',
'azure_openai:o1-mini',
'azure_openai:o3-mini',
'mistral:codestral-latest',
// deepseek — vision exists in their chat product, not in the API
'deepseek:deepseek-v4-pro',
'deepseek:deepseek-v4-flash',
'deepseek:deepseek-chat',
'deepseek:deepseek-reasoner',
'groq:llama-3.3-70b-versatile',
'groq:llama-3.1-8b-instant',
// gpt-oss (text-only everywhere it is hosted) — on groq it succeeds the two
// llama defaults above, which retire 2026-08-16
'groq:openai/gpt-oss-120b',
'groq:openai/gpt-oss-20b',
'openrouter:openai/gpt-oss-120b',
'openrouter:openai/gpt-oss-20b',
'togetherai:openai/gpt-oss-120b',
'togetherai:openai/gpt-oss-20b',
// azure_foundry serves DeepSeek-V4-Pro under the same id as deepseek's API
'azure_foundry:deepseek-v4-pro',
'azure_foundry:deepseek-r1',
'azure_foundry:llama-3.3-70b-instruct',
'azure_foundry:phi-4',
'azure_foundry:mistral-large-2411',
'openrouter:meta-llama/llama-3.2-3b-instruct:free',
'togetherai:meta-llama/llama-3.3-70b-instruct-turbo'
])