@broberg/ai-sdk 0.34.0 → 0.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -22,8 +22,14 @@ var DEFAULT_TIER_MAP = {
22
22
  // NOT Anthropic → out of F030 (EU-embedding migration is its own future epic).
23
23
  embedding: { provider: "openai", model: "text-embedding-3-small", transport: "http" }
24
24
  };
25
- function resolveTier(tier, override, configMap) {
25
+ function resolveTier(tier, override, configMap, knownProviders) {
26
26
  const base = configMap?.[tier] ?? DEFAULT_TIER_MAP[tier];
27
+ const providerIsReal = knownProviders === void 0 || knownProviders.includes(override?.provider ?? "");
28
+ if (providerIsReal && override?.provider && override.model === void 0 && override.provider !== base.provider) {
29
+ throw new Error(
30
+ `createAI: override sets provider "${override.provider}", but tier "${tier}" resolves to model "${base.model}", which belongs to "${base.provider}". Set a model too, e.g. override: { provider: "${override.provider}", model: "<a ${override.provider} model>" }.`
31
+ );
32
+ }
27
33
  return { ...base, ...override };
28
34
  }
29
35
 
@@ -188,4 +194,4 @@ export {
188
194
  listModels,
189
195
  resolveModel
190
196
  };
191
- //# sourceMappingURL=chunk-OKR36NRQ.js.map
197
+ //# sourceMappingURL=chunk-Y3EMGZFH.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/routing/tier-map.ts","../src/availability/registry.ts","../src/availability/types.ts","../src/availability/resolve.ts"],"sourcesContent":["// Tier routing: a named Tier resolves to a concrete (provider, model, transport).\n// Precedence is per-call override > client config map > built-in defaults.\nimport type { Tier, TierSpec } from \"../types.js\";\n\n/** Built-in defaults. Every entry is overridable via AiConfig.defaults or a\n * per-call override.\n *\n * F030 — Anthropic API phase-out: `ANTHROPIC_API_KEY` was globally removed, so the\n * default cloud route may NOT hit Anthropic Console. `fast`/`smart`/`powerful`/\n * `vision` now default to **Mistral EU** (Paris-hosted, Schrems II-safe — so every\n * default text/vision call is GDPR-safe by default). Claude stays reachable as a\n * NON-default quality fallback for non-PII via `override:{provider:\"openrouter\",\n * model:\"anthropic/claude-…\"}`. DeepSeek (CN) is the opt-in non-PII secondary\n * (`provider:\"deepseek\"`), never a default. Magistral (reasoning) / mistral-large\n * for vision are per-call overrides, not defaults (don't pay the premium on all). */\nexport const DEFAULT_TIER_MAP: Record<Tier, TierSpec> = {\n fast: { provider: \"mistral\", model: \"mistral-small-latest\", transport: \"http\" },\n smart: { provider: \"mistral\", model: \"mistral-large-latest\", transport: \"http\" },\n powerful: { provider: \"mistral\", model: \"mistral-large-latest\", transport: \"http\" },\n cheap: { provider: \"mistral\", model: \"mistral-small-latest\", transport: \"http\" },\n // Vision: small-latest (vision-capable, cheap EU) is the default; override to\n // mistral-large-latest for demanding image/spatial/composition work.\n // F041 — bumped from mistral-small on Christian's ask, and the choice is MEASURED,\n // not assumed from price. On a fine-discrimination test (an 8x10 grid where one cell\n // differs only in its blue channel, 190->150) across 9 cases:\n // mistral-medium-latest 4/9 <- best\n // mistral-small-latest 1/6 + 0/3 = 1/9\n // mistral-large-latest 0/9 <- WORSE than small, despite costing 5x more\n // Large ties small on easy colour blocks (4/4 each) and collapses on subtle ones,\n // so \"bigger is better at vision\" does not hold in Mistral's lineup. Nobody is good\n // at this task; medium is simply the only one that sees anything.\n vision: { provider: \"mistral\", model: \"mistral-medium-latest\", transport: \"http\" },\n // Native video understanding — Gemini leads; flash-lite is the cheap default (F019).\n // NOT Anthropic → out of the F030 phase-out (its own EU epic if/when needed).\n video: { provider: \"gemini\", model: \"gemini-2.5-flash-lite\", transport: \"http\" },\n // NOT Anthropic → out of F030 (EU-embedding migration is its own future epic).\n embedding: { provider: \"openai\", model: \"text-embedding-3-small\", transport: \"http\" },\n};\n\n/**\n * Resolve a Tier to a concrete TierSpec.\n *\n * Merge order (later wins): DEFAULT_TIER_MAP < configMap < override.\n * - `configMap` is the client-level AiConfig.defaults (per-tier full specs).\n * - `override` is a per-call Partial<TierSpec> — only the fields it sets win.\n */\nexport function resolveTier(\n tier: Tier,\n override?: Partial<TierSpec>,\n configMap?: Partial<Record<Tier, TierSpec>>,\n knownProviders?: readonly string[],\n): TierSpec {\n const base = configMap?.[tier] ?? DEFAULT_TIER_MAP[tier];\n // F043: a provider-only override used to keep the TIER's model, so\n // `override:{provider:\"anthropic\"}` on tier \"cheap\" sent mistral-small-latest to\n // Anthropic's endpoint. Measured by coverletter 4/4 with distinct request_ids, and\n // it is the natural thing to write — it was literally the advice given to a repo\n // working around a missing Mistral key, so the escape hatch produced an error that\n // looked like \"Anthropic is down\".\n //\n // We REFUSE rather than re-resolve. Picking a model for the new provider would mean\n // the SDK making a price choice on the caller's behalf, and the bill would be the\n // only place that choice was visible. An explicit error costs one line to fix and\n // cannot be misread.\n // A provider the client has never heard of is a TYPO, and \"no adapter registered\n // for \\\"nope\\\"\" is the useful thing to say about it. Telling that caller to also set\n // a model would send them down a road that cannot work, so the mismatch guard steps\n // aside and lets the registry answer.\n const providerIsReal = knownProviders === undefined || knownProviders.includes(override?.provider ?? \"\");\n if (providerIsReal && override?.provider && override.model === undefined && override.provider !== base.provider) {\n throw new Error(\n `createAI: override sets provider \"${override.provider}\", but tier \"${tier}\" resolves to model ` +\n `\"${base.model}\", which belongs to \"${base.provider}\". Set a model too, e.g. ` +\n `override: { provider: \"${override.provider}\", model: \"<a ${override.provider} model>\" }.`,\n );\n }\n return { ...base, ...override };\n}\n","// F022 — the model-availability registry: the ONE source both resolveModel()\n// (spawn / call path) and listModels() (UI picker) read. A curated default seed\n// (works offline — the durable floor) plus a mutable overlay that\n// refreshAvailability() updates from the live provider list.\n//\n// Scope note: this is a LIVENESS view (is this id alive right now?), not the\n// rich capability/price inventory (that is F017 src/catalogue). We only track\n// ids we want to assert status on; anything not here is fail-open (treated\n// available) so we never block a model we simply do not track.\nimport { DEFAULT_TIER_MAP } from \"../routing/tier-map.js\";\nimport type { AvailabilityStatus, AvailabilitySource, ModelStatus } from \"./types.js\";\n\n/** Internal registry row. `aliases[0]` surfaces as ModelStatus.alias. */\nexport interface RegistryEntry {\n id: string;\n aliases: string[];\n provider: string;\n available: boolean;\n status: AvailabilityStatus;\n note?: string;\n source: AvailabilitySource;\n}\n\nconst SUSPENDED_FABLE_MYTHOS = \"suspended — US export-control directive (2026-06-12)\";\n\n/** Curated defaults. Aliases here are MODEL-IDENTITY names only (short names for\n * the model itself). TIER aliases — fast/smart/powerful/cheap/vision/video/\n * embedding — are NOT written here: they are derived from DEFAULT_TIER_MAP below,\n * because hand-maintaining them in two places is exactly how they drifted.\n *\n * They HAD drifted, and it crossed the EU border: this list said `smart` was\n * claude-sonnet-4-6 (Anthropic, US) for the ~3 months after F030 pointed the\n * default tiers at Mistral EU, so resolveModel('smart') named a US model while\n * ai.chat({tier:'smart'}) called an EU one. Anyone using resolveModel to SHOW or\n * DECIDE where data goes — the obvious use — got the wrong answer. Measured by\n * fd-sundhed in 0.21.1, still true in 0.28.0, fixed here by deleting the second\n * list rather than correcting it. */\nconst DEFAULTS: RegistryEntry[] = [\n // ── Anthropic ────────────────────────────────────────────────────────────\n { id: \"claude-haiku-4-5\", aliases: [\"haiku\"], provider: \"anthropic\", available: true, status: \"available\", source: \"default\" },\n { id: \"claude-sonnet-4-6\", aliases: [\"sonnet\"], provider: \"anthropic\", available: true, status: \"available\", source: \"default\" },\n { id: \"claude-opus-4-8\", aliases: [\"opus\"], provider: \"anthropic\", available: true, status: \"available\", source: \"default\" },\n { id: \"claude-fable-5\", aliases: [\"fable\"], provider: \"anthropic\", available: true, status: \"available\", source: \"default\" },\n { id: \"claude-mythos-5\", aliases: [\"mythos\"], provider: \"anthropic\", available: false, status: \"suspended\", note: SUSPENDED_FABLE_MYTHOS, source: \"default\" },\n // ── Gemini ───────────────────────────────────────────────────────────────\n { id: \"gemini-2.5-flash\", aliases: [\"gemini-flash\"], provider: \"gemini\", available: true, status: \"available\", source: \"default\" },\n { id: \"gemini-2.5-flash-lite\", aliases: [\"gemini-flash-lite\"], provider: \"gemini\", available: true, status: \"available\", source: \"default\" },\n // ── OpenAI ───────────────────────────────────────────────────────────────\n { id: \"text-embedding-3-small\", aliases: [], provider: \"openai\", available: true, status: \"available\", source: \"default\" },\n // ── Mistral (EU / GDPR) ──────────────────────────────────────────────────\n { id: \"mistral-large-latest\", aliases: [\"mistral-large\"], provider: \"mistral\", available: true, status: \"available\", source: \"default\" },\n { id: \"mistral-medium-latest\", aliases: [\"mistral-medium\"], provider: \"mistral\", available: true, status: \"available\", source: \"default\" },\n { id: \"mistral-small-latest\", aliases: [\"mistral-small\"], provider: \"mistral\", available: true, status: \"available\", source: \"default\" },\n];\n\n/** Attach every tier name as an alias of the model that tier ACTUALLY calls.\n * One source: DEFAULT_TIER_MAP decides, the registry follows. A tier whose model\n * is not in the registry is left unaliased on purpose — resolveModel then reports\n * status \"unknown\" (fail-open) instead of inventing a row, and the drift test\n * catches it. */\nexport const TIER_ALIAS_CONFLICTS: string[] = [];\n\nfunction applyTierAliases(entries: RegistryEntry[]): RegistryEntry[] {\n for (const [tier, spec] of Object.entries(DEFAULT_TIER_MAP)) {\n const owner = entries.find((e) => e.id === spec.model && e.provider === spec.provider);\n // A tier name hand-written on any OTHER row is the drift bug returning. It\n // would resolve correctly TODAY only because seed() lets the last write win\n // and the rows happen to be ordered favourably — reorder the array and the\n // tier silently points at the wrong provider again. Record it so a test can\n // fail on it, then strip it so runtime is right regardless.\n for (const e of entries) {\n if (e !== owner && e.aliases.includes(tier)) {\n TIER_ALIAS_CONFLICTS.push(`${tier} is hand-declared on ${e.id} (${e.provider}) but tier ${tier} calls ${spec.model} (${spec.provider})`);\n e.aliases = e.aliases.filter((a) => a !== tier);\n }\n }\n if (owner && !owner.aliases.includes(tier)) owner.aliases.push(tier);\n }\n return entries;\n}\napplyTierAliases(DEFAULTS);\n\n/** The live overlay, keyed by canonical id. Seeded from DEFAULTS (deep-copied so\n * resetting is clean). refreshAvailability() mutates this; resolve/listModels\n * read it synchronously. */\nlet OVERLAY = new Map<string, RegistryEntry>();\n/** alias → canonical id, rebuilt whenever the overlay is seeded. */\nlet ALIAS_INDEX = new Map<string, string>();\n\nfunction seed(): void {\n OVERLAY = new Map(DEFAULTS.map((e) => [e.id, { ...e, aliases: [...e.aliases] }]));\n ALIAS_INDEX = new Map();\n for (const e of DEFAULTS) for (const a of e.aliases) ALIAS_INDEX.set(a, e.id);\n}\nseed();\n\n/** Reset the overlay back to the curated defaults. For tests. */\nexport function resetRegistry(): void {\n seed();\n}\n\n/** Canonical id for a model id OR alias; null when we track neither. */\nexport function canonicalId(requested: string): string | null {\n if (OVERLAY.has(requested)) return requested;\n return ALIAS_INDEX.get(requested) ?? null;\n}\n\n/** The current entry for an id/alias, or undefined when untracked (fail-open). */\nexport function getEntry(requested: string): RegistryEntry | undefined {\n const id = canonicalId(requested);\n return id ? OVERLAY.get(id) : undefined;\n}\n\n/** All tracked entries (optionally provider-scoped), as a public ModelStatus[]. */\nexport function allEntries(provider?: string): ModelStatus[] {\n const rows: ModelStatus[] = [];\n for (const e of OVERLAY.values()) {\n if (provider && e.provider !== provider) continue;\n rows.push({\n id: e.id,\n alias: e.aliases[0],\n provider: e.provider,\n available: e.available,\n status: e.status,\n note: e.note,\n source: e.source,\n });\n }\n return rows;\n}\n\n/** Provider-scoped canonical ids (for refresh reconciliation). */\nexport function providerIds(provider: string): string[] {\n return [...OVERLAY.values()].filter((e) => e.provider === provider).map((e) => e.id);\n}\n\n/** Mark a tracked id available/suspended from a live refresh. No-op if untracked. */\nexport function setAvailability(id: string, available: boolean, note?: string): void {\n const e = OVERLAY.get(id);\n if (!e) return;\n e.available = available;\n e.status = available ? \"available\" : \"suspended\";\n e.source = \"refresh\";\n if (note !== undefined) e.note = note;\n else if (available) e.note = undefined;\n}\n","// F022 — Model Availability Harness. Public types for the availability layer:\n// the shared status read (ModelStatus), the resolve result, and the structured\n// error a caller can flag on. The registry is the one source both the spawn /\n// call path (resolveModel) and UI pickers (listModels) read.\n\nexport type AvailabilityStatus = \"available\" | \"suspended\" | \"unknown\";\n\n/** Where a model's current availability came from: the curated default seed,\n * or a live provider refresh (Anthropic GET /v1/models). */\nexport type AvailabilitySource = \"default\" | \"refresh\";\n\n/** One row of the shared status read — what a UI model-picker renders. */\nexport interface ModelStatus {\n /** Canonical provider model id, e.g. \"claude-fable-5\". */\n id: string;\n /** Short/tier alias, e.g. \"fable\" (the first registered alias). */\n alias?: string;\n /** \"anthropic\" | \"openai\" | \"gemini\" | \"mistral\" | … */\n provider: string;\n available: boolean;\n status: AvailabilityStatus;\n /** Friendly reason, e.g. \"suspended — US export-control directive (2026-06-12)\". */\n note?: string;\n source: AvailabilitySource;\n}\n\n/** Result of resolveModel — the spawn / call path consumes this synchronously. */\nexport interface ResolveResult {\n /** True when the requested model itself is available. */\n ok: boolean;\n /** The id to actually use: `requested` when ok, else the first available fallback. */\n model: string;\n /** What the caller asked for (id or alias, normalized to the canonical id). */\n requested: string;\n provider?: string;\n /** True when `model` differs from `requested` because we fell back. */\n fellBack: boolean;\n status: AvailabilityStatus;\n /** Why it degraded / why it is unavailable. */\n reason?: string;\n}\n\n/** Thrown by resolveModel when the requested model is unavailable, no usable\n * fallback exists, and the caller passed `throwIfUnavailable`. Callers flag on\n * `.code === \"model_unavailable\"`. */\nexport class ModelUnavailableError extends Error {\n readonly code = \"model_unavailable\";\n readonly requested: string;\n readonly provider?: string;\n readonly note?: string;\n constructor(requested: string, note?: string, provider?: string) {\n super(`model \"${requested}\" is unavailable${note ? ` (${note})` : \"\"}`);\n this.name = \"ModelUnavailableError\";\n this.requested = requested;\n this.note = note;\n this.provider = provider;\n }\n}\n","// F022 — the synchronous, zero-I/O resolve + status read. This is the spawn /\n// call hot path (buddy's launcher calls resolveModel per spawn, cardmem #4842):\n// it MUST never await and never touch the network. Freshness comes only from a\n// prior async refreshAvailability(); resolve just reads the in-memory registry.\nimport { allEntries, canonicalId, getEntry } from \"./registry.js\";\nimport { ModelUnavailableError } from \"./types.js\";\nimport type { ModelStatus, ResolveResult } from \"./types.js\";\n\nexport interface ResolveOptions {\n /** One id/alias or an ordered chain to try when `requested` is unavailable. */\n fallback?: string | string[];\n /** Scope hint (passed through to the result); does not gate lookup. */\n provider?: string;\n /** Throw ModelUnavailableError instead of returning ok:false when there is no\n * usable fallback. For callers that want to flag rather than degrade. */\n throwIfUnavailable?: boolean;\n /** Treat an id we do not track as UNUSABLE instead of fail-open.\n *\n * Default is fail-open: we never block a model we simply do not track. That is\n * right for liveness, and wrong for a caller who is GATING — cms measured the\n * consequence: resolveModel(\"cheap\") answered {ok:true, model:\"cheap\"}, so a\n * consumer following our own instruction to gate on `ok` passed the gate and\n * then sent the literal string \"cheap\" to a provider as a model id. A\n * success-shaped non-answer is worse than an error, because an error gets\n * handled and a shape does not.\n *\n * Set this when you need \"a model you actually know about\". Off by default so\n * no existing caller changes behaviour. */\n requireKnown?: boolean;\n}\n\n/** The shared status read — UI pickers grey out `available:false` rows. */\nexport function listModels(opts: { provider?: string } = {}): ModelStatus[] {\n return allEntries(opts.provider);\n}\n\n/** Is this id/alias currently usable? Untracked ids are fail-open (true) unless\n * the caller asked for requireKnown. */\nfunction isAvailable(requested: string, requireKnown = false): boolean {\n const e = getEntry(requested);\n if (!e) return !requireKnown; // fail-open on unknown, unless gating\n return e.available;\n}\n\n/**\n * Resolve a requested model (id or alias) to one that is actually usable.\n * Synchronous + offline by contract (cardmem #4842) — reads the registry only.\n *\n * - Available → pass through ({ ok:true, fellBack:false }).\n * - Unavailable + a fallback that IS available → swap ({ ok:false, fellBack:true }).\n * - Unavailable + no usable fallback → throw (throwIfUnavailable) or return ok:false.\n * - Unknown id → treated available (never block a model we do not track).\n */\nexport function resolveModel(requested: string, opts: ResolveOptions = {}): ResolveResult {\n const id = canonicalId(requested) ?? requested;\n const entry = getEntry(requested);\n const provider = opts.provider ?? entry?.provider;\n\n if (isAvailable(requested, opts.requireKnown)) {\n return {\n ok: true,\n model: id,\n requested: id,\n provider,\n fellBack: false,\n status: entry?.status ?? \"unknown\",\n };\n }\n\n // Requested is suspended — walk the fallback chain for the first available one.\n const chain = opts.fallback === undefined ? [] : Array.isArray(opts.fallback) ? opts.fallback : [opts.fallback];\n for (const fb of chain) {\n if (isAvailable(fb, opts.requireKnown)) {\n const fbId = canonicalId(fb) ?? fb;\n return {\n ok: false,\n model: fbId,\n requested: id,\n provider,\n fellBack: true,\n status: entry?.status ?? \"suspended\",\n reason: entry?.note ?? `${id} is unavailable`,\n };\n }\n }\n\n // No usable fallback.\n const unknownAndGating = !entry && opts.requireKnown;\n const reason = unknownAndGating\n ? `${id} is not a model this registry knows — requireKnown was set, so it is not assumed usable`\n : (entry?.note ?? `${id} is unavailable`);\n if (opts.throwIfUnavailable) {\n throw new ModelUnavailableError(id, reason, provider);\n }\n return {\n ok: false,\n model: id,\n requested: id,\n provider,\n fellBack: false,\n status: entry?.status ?? \"unknown\",\n reason,\n };\n}\n"],"mappings":"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","names":[]}
package/dist/index.d.ts CHANGED
@@ -1,6 +1,21 @@
1
1
  import { z } from 'zod';
2
2
  export { AvailabilitySource, AvailabilityStatus, ModelStatus, ModelUnavailableError, ResolveOptions, ResolveResult, listModels, resolveModel } from './registry.js';
3
3
 
4
+ /** Coarse data-residency of the endpoint a call went to.
5
+ *
6
+ * - `"eu"` — EU/EEA. **The only positive residency claim.** Nothing else is folded
7
+ * in: an adequacy decision is not EU residency, so the UK, Switzerland
8
+ * and Canada are deliberately NOT `"eu"` here.
9
+ * - `"us"` — United States.
10
+ * - `"cn"` — China.
11
+ * - `"unknown"` — we genuinely cannot say: an aggregator that picks its own upstream,
12
+ * or a region string we do not recognise.
13
+ *
14
+ * **`"unknown"` is NOT a synonym for safe.** `region !== "us"` is not an EU check —
15
+ * it passes every OpenRouter call. Only `region === "eu"` may be treated as EU-resident.
16
+ */
17
+ type Region = "eu" | "us" | "cn" | "unknown";
18
+
4
19
  /** How a call reaches the model. `http` = provider REST API; `subprocess` = local
5
20
  * `claude -p` CLI (Max plan, costUsd 0). */
6
21
  type Transport = "http" | "subprocess";
@@ -32,8 +47,9 @@ type ContentPart = {
32
47
  interface Message {
33
48
  role: Role;
34
49
  content: string | ContentPart[];
35
- /** Set on assistant messages that called tools. */
36
- toolCalls?: ToolCall[];
50
+ /** Set on assistant messages that called tools. Accepts either spelling of the
51
+ * arguments field — see ToolCallLike. */
52
+ toolCalls?: ToolCallLike[];
37
53
  /** Set on `tool` role messages — which call this result answers. */
38
54
  toolCallId?: string;
39
55
  }
@@ -44,12 +60,31 @@ interface Tool {
44
60
  description: string;
45
61
  parameters: Record<string, unknown>;
46
62
  }
47
- /** A model's request to invoke a tool, normalized across providers (F4.5). */
63
+ /** A model's request to invoke a tool, normalized across providers (F4.5).
64
+ * This is what we EMIT — `arguments` is always present on a ToolCall we return. */
48
65
  interface ToolCall {
49
66
  id: string;
50
67
  name: string;
51
68
  arguments: Record<string, unknown>;
52
69
  }
70
+ /** What we ACCEPT when a tool call is handed BACK to us in a message history.
71
+ *
72
+ * F043. `@broberg/chat` spells the same field `args`, consistently across its
73
+ * ModelEvent / ChatFrame / ChatTool.run. Two packages describing the same thing
74
+ * with two names cost cms a production outage: a rename living in a workaround was
75
+ * deleted along with the workaround, and the next call failed with
76
+ * `messages.1.toolCalls.0.arguments — Required`.
77
+ *
78
+ * So we read both and keep emitting `arguments`. Accepting is cheap and cannot
79
+ * break anyone; renaming would have broken a consumer who adopted the same day, and
80
+ * asking every consumer that bridges the two packages to remember a translation is
81
+ * how this happens again. Precedence is `arguments` when both are set. */
82
+ type ToolCallLike = {
83
+ id: string;
84
+ name: string;
85
+ arguments?: Record<string, unknown>;
86
+ args?: Record<string, unknown>;
87
+ };
53
88
  /** Per-call usage. Fields mirror the upmetrics `agent_runs` schema 1:1 so the
54
89
  * upmetricsSink (F3.7) forwards without re-mapping. `costUsd` is 0 for
55
90
  * subprocess (Max plan); `subprocess:true` lets dashboards split free vs paid. */
@@ -57,6 +92,14 @@ interface Usage {
57
92
  provider: string;
58
93
  model: string;
59
94
  tier?: Tier;
95
+ /** Data-residency of the endpoint that ACTUALLY answered (F042). Derived from the
96
+ * host/region the request used, not from the provider's name — vertex, azure and
97
+ * bfl are EU by default but a consumer can point them elsewhere.
98
+ *
99
+ * **Only `"eu"` is a positive residency claim.** `"unknown"` means we cannot say
100
+ * (an aggregator picked its own upstream, or the region string is one we do not
101
+ * recognise) — it is NOT a synonym for safe, so `region !== "us"` is not an EU check. */
102
+ region: Region;
60
103
  transport: Transport;
61
104
  inputTokens: number;
62
105
  outputTokens: number;
@@ -2103,8 +2146,8 @@ declare const falStubAdapter: ProviderAdapter;
2103
2146
  * wires the live adapters. */
2104
2147
  declare const stubProviders: Record<string, ProviderAdapter>;
2105
2148
 
2106
- declare const VERSION: "0.34.0";
2107
- declare const SDK_TAG: "@broberg/ai-sdk@0.34.0";
2149
+ declare const VERSION: "0.35.0";
2150
+ declare const SDK_TAG: "@broberg/ai-sdk@0.35.0";
2108
2151
 
2109
2152
  /** Built-in defaults. Every entry is overridable via AiConfig.defaults or a
2110
2153
  * per-call override.
@@ -2125,7 +2168,7 @@ declare const DEFAULT_TIER_MAP: Record<Tier, TierSpec>;
2125
2168
  * - `configMap` is the client-level AiConfig.defaults (per-tier full specs).
2126
2169
  * - `override` is a per-call Partial<TierSpec> — only the fields it sets win.
2127
2170
  */
2128
- declare function resolveTier(tier: Tier, override?: Partial<TierSpec>, configMap?: Partial<Record<Tier, TierSpec>>): TierSpec;
2171
+ declare function resolveTier(tier: Tier, override?: Partial<TierSpec>, configMap?: Partial<Record<Tier, TierSpec>>, knownProviders?: readonly string[]): TierSpec;
2129
2172
 
2130
2173
  interface RefreshOptions {
2131
2174
  /** Only "anthropic" is wired in v1 (where the incident hit). */
@@ -2257,6 +2300,11 @@ declare function freshUsage(args: {
2257
2300
  outputTokens: number;
2258
2301
  cacheReadTokens?: number;
2259
2302
  cacheCreationTokens?: number;
2303
+ /** Data residency of the endpoint this call ACTUALLY used (F042). Pass it from any
2304
+ * adapter whose region a consumer can change — vertex, azure, bfl. Omitting it
2305
+ * falls back to the fixed provider table, which does not list those three, so a
2306
+ * forgetful adapter degrades to "unknown" rather than to a false "eu". */
2307
+ region?: Region;
2260
2308
  subprocess?: boolean;
2261
2309
  }): Usage;
2262
2310
 
package/dist/index.js CHANGED
@@ -7,7 +7,7 @@ import {
7
7
  resolveModel,
8
8
  resolveTier,
9
9
  setAvailability
10
- } from "./chunk-OKR36NRQ.js";
10
+ } from "./chunk-Y3EMGZFH.js";
11
11
  import {
12
12
  getPrice
13
13
  } from "./chunk-TENEIW7I.js";
@@ -26,6 +26,15 @@ async function httpTransport(req) {
26
26
  const json = await res.json().catch(() => void 0);
27
27
  return { ok: res.ok, status: res.status, json };
28
28
  }
29
+ function errorBody(json, fallback = "(no body)") {
30
+ if (json === void 0 || json === null) return fallback;
31
+ if (typeof json === "string") return json.slice(0, 300);
32
+ try {
33
+ return (JSON.stringify(json) ?? fallback).slice(0, 300);
34
+ } catch {
35
+ return fallback;
36
+ }
37
+ }
29
38
 
30
39
  // src/transport/subprocess.ts
31
40
  function parseClaudeCliJson(raw) {
@@ -202,6 +211,74 @@ function parseArgs(raw) {
202
211
  }
203
212
  return {};
204
213
  }
214
+ function toolCallArgs(tc) {
215
+ return tc.arguments ?? tc.args ?? {};
216
+ }
217
+
218
+ // src/cost/region.ts
219
+ var FIXED_PROVIDER_REGION = {
220
+ // api.mistral.ai — Paris. The designated EU/GDPR route for personal data.
221
+ mistral: "eu",
222
+ // api.deepl.com / api-free.deepl.com — EU-hosted (Falun, Sweden).
223
+ deepl: "eu",
224
+ openai: "us",
225
+ anthropic: "us",
226
+ // generativelanguage.googleapis.com is Google's GLOBAL endpoint, not a US-pinned one.
227
+ // We report "us" rather than "unknown" because it is certainly not EU-resident, and
228
+ // the honest error direction for a residency field is away from an EU claim. Vertex
229
+ // (below, region-pinned) is the route to use when EU residency is required.
230
+ gemini: "us",
231
+ deepinfra: "us",
232
+ fal: "us",
233
+ elevenlabs: "us",
234
+ // api.deepseek.com — People's Republic of China. No EU adequacy decision at all,
235
+ // which is a materially different position from the US, hence its own value.
236
+ deepseek: "cn",
237
+ // Aggregators: they choose the upstream provider per request, so the region is not
238
+ // ours to know. Reporting anything else here would be inventing a fact.
239
+ openrouter: "unknown",
240
+ requesty: "unknown"
241
+ };
242
+ var EU_REGION_NAMES = /* @__PURE__ */ new Set([
243
+ // Azure
244
+ "westeurope",
245
+ "northeurope",
246
+ "swedencentral",
247
+ "francecentral",
248
+ "francesouth",
249
+ "germanywestcentral",
250
+ "germanynorth",
251
+ "norwayeast",
252
+ "norwaywest",
253
+ "polandcentral",
254
+ "italynorth",
255
+ "spaincentral"
256
+ ]);
257
+ var US_REGION_NAMES = /* @__PURE__ */ new Set([
258
+ // Azure
259
+ "eastus",
260
+ "eastus2",
261
+ "westus",
262
+ "westus2",
263
+ "westus3",
264
+ "centralus",
265
+ "northcentralus",
266
+ "southcentralus",
267
+ "westcentralus"
268
+ ]);
269
+ function classifyRegionName(name) {
270
+ if (!name) return "unknown";
271
+ const n = name.trim().toLowerCase();
272
+ if (!n) return "unknown";
273
+ if (n.startsWith("europe-")) return "eu";
274
+ if (n.startsWith("us-")) return "us";
275
+ if (EU_REGION_NAMES.has(n)) return "eu";
276
+ if (US_REGION_NAMES.has(n)) return "us";
277
+ return "unknown";
278
+ }
279
+ function regionOfProvider(provider) {
280
+ return FIXED_PROVIDER_REGION[provider] ?? "unknown";
281
+ }
205
282
 
206
283
  // src/cost/usage.ts
207
284
  function computeCost(provider, model, inputTokens, outputTokens, cacheReadTokens = 0, cacheCreationTokens = 0) {
@@ -228,6 +305,7 @@ function freshUsage(args) {
228
305
  const usage = {
229
306
  provider: args.provider,
230
307
  model: args.model,
308
+ region: args.region ?? regionOfProvider(args.provider),
231
309
  transport: args.transport,
232
310
  inputTokens: args.inputTokens,
233
311
  outputTokens: args.outputTokens,
@@ -302,7 +380,7 @@ function anthropicAdapter(config = {}) {
302
380
  blocks.push(...contentBlocks(m.content));
303
381
  }
304
382
  for (const tc of m.toolCalls) {
305
- blocks.push({ type: "tool_use", id: tc.id, name: tc.name, input: tc.arguments });
383
+ blocks.push({ type: "tool_use", id: tc.id, name: tc.name, input: toolCallArgs(tc) });
306
384
  }
307
385
  messages.push({ role: "assistant", content: blocks });
308
386
  continue;
@@ -340,7 +418,7 @@ function anthropicAdapter(config = {}) {
340
418
  body
341
419
  }
342
420
  });
343
- if (!res.ok) throw new Error(`anthropic ${res.status}: ${JSON.stringify(res.json).slice(0, 300)}`);
421
+ if (!res.ok) throw new Error(`anthropic ${res.status}: ${errorBody(res.json)}`);
344
422
  const data = res.json;
345
423
  const blocks = data.content ?? [];
346
424
  const text = blocks.filter((b) => b.type === "text" && typeof b.text === "string").map((b) => b.text).join("");
@@ -503,7 +581,7 @@ function toOpenAIMessage(m) {
503
581
  base.tool_calls = m.toolCalls.map((tc) => ({
504
582
  id: tc.id,
505
583
  type: "function",
506
- function: { name: tc.name, arguments: JSON.stringify(tc.arguments) }
584
+ function: { name: tc.name, arguments: JSON.stringify(toolCallArgs(tc)) }
507
585
  }));
508
586
  }
509
587
  return base;
@@ -519,25 +597,29 @@ function toOpenAIMessage(m) {
519
597
  });
520
598
  return { role: m.role, content };
521
599
  }
600
+ function buildChatBody(req, config) {
601
+ const body = {
602
+ model: req.spec.model,
603
+ messages: req.messages.map(toOpenAIMessage)
604
+ };
605
+ if (req.tools) body.tools = toProviderTools(req.tools, "openai");
606
+ if (req.maxTokens !== void 0) body.max_tokens = req.maxTokens;
607
+ if (req.temperature !== void 0) body.temperature = req.temperature;
608
+ if (req.responseFormat === "json") body.response_format = { type: "json_object" };
609
+ if (config.supportsPromptCacheKey && req.promptCache !== false) {
610
+ const k = req.promptCacheKey ?? autoCacheKey(req.messages);
611
+ if (k !== void 0) body.prompt_cache_key = k;
612
+ }
613
+ if (config.costFromResponseField) body.usage = { include: true };
614
+ return body;
615
+ }
522
616
  function makeOpenAICompatibleAdapter(config) {
523
617
  async function chat(req) {
524
618
  const apiKey = config.apiKey ?? process.env[`${config.name.toUpperCase()}_API_KEY`];
525
619
  if (!apiKey) {
526
620
  throw new Error(`${config.name} adapter: API key not set (env ${config.name.toUpperCase()}_API_KEY)`);
527
621
  }
528
- const body = {
529
- model: req.spec.model,
530
- messages: req.messages.map(toOpenAIMessage)
531
- };
532
- if (req.tools) body.tools = toProviderTools(req.tools, "openai");
533
- if (req.maxTokens !== void 0) body.max_tokens = req.maxTokens;
534
- if (req.temperature !== void 0) body.temperature = req.temperature;
535
- if (req.responseFormat === "json") body.response_format = { type: "json_object" };
536
- if (config.supportsPromptCacheKey && req.promptCache !== false) {
537
- const k = req.promptCacheKey ?? autoCacheKey(req.messages);
538
- if (k !== void 0) body.prompt_cache_key = k;
539
- }
540
- if (config.costFromResponseField) body.usage = { include: true };
622
+ const body = buildChatBody(req, config);
541
623
  const res = await httpTransport({
542
624
  spec: req.spec,
543
625
  http: {
@@ -551,7 +633,7 @@ function makeOpenAICompatibleAdapter(config) {
551
633
  }
552
634
  });
553
635
  if (!res.ok) {
554
- throw new Error(`${config.name} ${res.status}: ${JSON.stringify(res.json).slice(0, 300)}`);
636
+ throw new Error(`${config.name} ${res.status}: ${errorBody(res.json)}`);
555
637
  }
556
638
  const data = res.json;
557
639
  const msg = data.choices?.[0]?.message;
@@ -586,16 +668,10 @@ function makeOpenAICompatibleAdapter(config) {
586
668
  throw new Error(`${config.name} adapter: API key not set (env ${config.name.toUpperCase()}_API_KEY)`);
587
669
  }
588
670
  const body = {
589
- model: req.spec.model,
590
- messages: req.messages.map(toOpenAIMessage),
671
+ ...buildChatBody(req, config),
591
672
  stream: true,
592
673
  stream_options: { include_usage: true }
593
674
  };
594
- if (req.tools) body.tools = toProviderTools(req.tools, "openai");
595
- if (req.maxTokens !== void 0) body.max_tokens = req.maxTokens;
596
- if (req.temperature !== void 0) body.temperature = req.temperature;
597
- if (req.responseFormat === "json") body.response_format = { type: "json_object" };
598
- if (config.costFromResponseField) body.usage = { include: true };
599
675
  const stream = streamTransport({
600
676
  spec: req.spec,
601
677
  fetch: config.fetch,
@@ -711,7 +787,7 @@ function openaiAdapter(config = {}) {
711
787
  }
712
788
  });
713
789
  if (!res.ok) {
714
- throw new Error(`openai ${res.status}: ${JSON.stringify(res.json).slice(0, 300)}`);
790
+ throw new Error(`openai ${res.status}: ${errorBody(res.json)}`);
715
791
  }
716
792
  const data = res.json;
717
793
  const vectors = (data.data ?? []).map((d) => d.embedding);
@@ -876,7 +952,7 @@ function geminiAdapter(config = {}) {
876
952
  }
877
953
  });
878
954
  if (!res.ok) {
879
- throw new Error(`gemini ${res.status}: ${JSON.stringify(res.json).slice(0, 300)}`);
955
+ throw new Error(`gemini ${res.status}: ${errorBody(res.json)}`);
880
956
  }
881
957
  const data = res.json;
882
958
  const parts = data.candidates?.[0]?.content?.parts ?? [];
@@ -1480,6 +1556,9 @@ function azureAdapter(config = {}) {
1480
1556
  const usage = freshUsage({
1481
1557
  provider: "azure",
1482
1558
  model,
1559
+ // Same region() that picked the host. westeurope by default, but
1560
+ // config.region / AZURE_SPEECH_REGION can point this at eastus.
1561
+ region: classifyRegionName(region()),
1483
1562
  transport: "http",
1484
1563
  capability: "tts",
1485
1564
  inputTokens: 0,
@@ -1540,6 +1619,7 @@ function azureAdapter(config = {}) {
1540
1619
  const usage = freshUsage({
1541
1620
  provider: "azure",
1542
1621
  model: req.spec.model,
1622
+ region: classifyRegionName(region()),
1543
1623
  transport: "http",
1544
1624
  capability: "transcribe",
1545
1625
  inputTokens: 0,
@@ -1713,6 +1793,10 @@ function vertexAdapter(config = {}) {
1713
1793
  const usage = freshUsage({
1714
1794
  provider: "vertex",
1715
1795
  model: req.spec.model,
1796
+ // Read from the SAME region() that built the URL above — this adapter is EU by
1797
+ // default, but config.region / GOOGLE_VERTEX_REGION can move it, and a residency
1798
+ // field that ignores the override is worse than none.
1799
+ region: classifyRegionName(region()),
1716
1800
  transport: "http",
1717
1801
  capability: "animate",
1718
1802
  inputTokens: 0,
@@ -1754,6 +1838,10 @@ function vertexAdapter(config = {}) {
1754
1838
  const usage = freshUsage({
1755
1839
  provider: "vertex",
1756
1840
  model: req.spec.model,
1841
+ // Read from the SAME region() that built the URL above — this adapter is EU by
1842
+ // default, but config.region / GOOGLE_VERTEX_REGION can move it, and a residency
1843
+ // field that ignores the override is worse than none.
1844
+ region: classifyRegionName(region()),
1757
1845
  transport: "http",
1758
1846
  capability: "vision",
1759
1847
  inputTokens,
@@ -2145,6 +2233,13 @@ async function bflCredits(opts = {}) {
2145
2233
  const credits = typeof data.credits === "number" ? data.credits : 0;
2146
2234
  return { credits, usd: credits * BFL_CREDIT_USD };
2147
2235
  }
2236
+ function bflRegion(base) {
2237
+ try {
2238
+ return new URL(base).host.toLowerCase() === "api.eu.bfl.ai" ? "eu" : "unknown";
2239
+ } catch {
2240
+ return "unknown";
2241
+ }
2242
+ }
2148
2243
  function bflAdapter(config = {}) {
2149
2244
  const doFetch = config.fetch ?? fetch;
2150
2245
  const base = config.baseUrl ?? EU_BASE2;
@@ -2191,6 +2286,7 @@ function bflAdapter(config = {}) {
2191
2286
  const usage = freshUsage({
2192
2287
  provider: "bfl",
2193
2288
  model: req.spec.model,
2289
+ region: bflRegion(base),
2194
2290
  transport: "http",
2195
2291
  capability: "image",
2196
2292
  inputTokens: 0,
@@ -2822,8 +2918,8 @@ var aiConfigSchema = z.object({
2822
2918
  });
2823
2919
 
2824
2920
  // src/version.ts
2825
- var VERSION = "0.34.0";
2826
- var SDK_TAG = "@broberg/ai-sdk@0.34.0";
2921
+ var VERSION = "0.35.0";
2922
+ var SDK_TAG = "@broberg/ai-sdk@0.35.0";
2827
2923
 
2828
2924
  // src/cost/sinks/upmetrics.ts
2829
2925
  function upmetricsSink(config) {
@@ -2953,6 +3049,7 @@ function createAI(config = {}) {
2953
3049
  async function settle(usage) {
2954
3050
  if (budget) await budget.record(usage.costUsd);
2955
3051
  }
3052
+ const providerNames = Object.keys(providers);
2956
3053
  function pickProvider(name) {
2957
3054
  const adapter = providers[name];
2958
3055
  if (!adapter) {
@@ -3043,7 +3140,7 @@ function createAI(config = {}) {
3043
3140
  );
3044
3141
  const estOut = input.maxTokens ?? 512;
3045
3142
  const routes = [
3046
- resolveTier(tier, input.override, cfg.defaults),
3143
+ resolveTier(tier, input.override, cfg.defaults, providerNames),
3047
3144
  ...(input.fallback ?? []).map(
3048
3145
  (f) => typeof f === "string" ? resolveTier(f, void 0, cfg.defaults) : f
3049
3146
  )
@@ -3065,7 +3162,13 @@ function createAI(config = {}) {
3065
3162
  tools: input.tools,
3066
3163
  maxTokens: input.maxTokens,
3067
3164
  temperature: input.temperature,
3068
- responseFormat: input.responseFormat
3165
+ responseFormat: input.responseFormat,
3166
+ // F043: these two were dropped here, so a streamed call could not cache even
3167
+ // after the adapter learned how. A chat UI streams every turn and repeats the
3168
+ // same system prompt each time — the call shape with the most to gain was the
3169
+ // one paying full price. Kept identical to the chat branch above on purpose.
3170
+ promptCacheKey: input.promptCacheKey,
3171
+ promptCache: input.promptCache ?? cfg.promptCache
3069
3172
  })) {
3070
3173
  if (ev.type === "text" || ev.type === "tool_call") emitted = true;
3071
3174
  if (ev.type === "usage") {
@@ -3096,7 +3199,7 @@ function createAI(config = {}) {
3096
3199
  0
3097
3200
  );
3098
3201
  return runCapability({
3099
- primary: resolveTier(tier, input.override, cfg.defaults),
3202
+ primary: resolveTier(tier, input.override, cfg.defaults, providerNames),
3100
3203
  fallback: input.fallback,
3101
3204
  capability: "chat",
3102
3205
  tier,
@@ -3117,7 +3220,7 @@ function createAI(config = {}) {
3117
3220
  const tier = input.tier ?? VISION_DEFAULT_TIER;
3118
3221
  const messages = buildVisionMessages(input);
3119
3222
  return runCapability({
3120
- primary: resolveTier(tier, input.override, cfg.defaults),
3223
+ primary: resolveTier(tier, input.override, cfg.defaults, providerNames),
3121
3224
  fallback: input.fallback,
3122
3225
  capability: "vision",
3123
3226
  tier,
@@ -3138,7 +3241,7 @@ function createAI(config = {}) {
3138
3241
  const tier = input.tier ?? VIDEO_DEFAULT_TIER;
3139
3242
  const messages = buildVideoMessages(input);
3140
3243
  return runCapability({
3141
- primary: resolveTier(tier, input.override, cfg.defaults),
3244
+ primary: resolveTier(tier, input.override, cfg.defaults, providerNames),
3142
3245
  fallback: input.fallback,
3143
3246
  capability: "video",
3144
3247
  tier,
@@ -3160,7 +3263,7 @@ function createAI(config = {}) {
3160
3263
  const messages = buildTranslateMessages(input);
3161
3264
  const estIn = estTokens(input.text) + 40;
3162
3265
  const res = await runCapability({
3163
- primary: resolveTier(tier, input.override, cfg.defaults),
3266
+ primary: resolveTier(tier, input.override, cfg.defaults, providerNames),
3164
3267
  fallback: input.fallback,
3165
3268
  capability: "translate",
3166
3269
  tier,
@@ -3350,7 +3453,7 @@ function createAI(config = {}) {
3350
3453
  const tier = input.tier ?? EMBEDDING_DEFAULT_TIER;
3351
3454
  const text = Array.isArray(input.text) ? input.text : [input.text];
3352
3455
  return runCapability({
3353
- primary: resolveTier(tier, input.override, cfg.defaults),
3456
+ primary: resolveTier(tier, input.override, cfg.defaults, providerNames),
3354
3457
  fallback: input.fallback,
3355
3458
  capability: "embedding",
3356
3459
  tier,
@@ -3416,6 +3519,9 @@ function stubUsage(provider, model, transport, capability) {
3416
3519
  return {
3417
3520
  provider,
3418
3521
  model,
3522
+ // Stubs answer no network, so there is no endpoint whose residency we could
3523
+ // report. "unknown" is the truthful reading — not a placeholder "eu".
3524
+ region: "unknown",
3419
3525
  transport,
3420
3526
  inputTokens: 0,
3421
3527
  outputTokens: 0,