@framers/agentos 0.12.2 → 0.12.4

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.
Files changed (61) hide show
  1. package/dist/api/agent.d.ts +7 -5
  2. package/dist/api/agent.d.ts.map +1 -1
  3. package/dist/api/agent.js.map +1 -1
  4. package/dist/api/generateObject.d.ts +5 -5
  5. package/dist/api/generateText.d.ts +161 -50
  6. package/dist/api/generateText.d.ts.map +1 -1
  7. package/dist/api/generateText.js +255 -78
  8. package/dist/api/generateText.js.map +1 -1
  9. package/dist/api/index.d.ts +6 -1
  10. package/dist/api/index.d.ts.map +1 -1
  11. package/dist/api/index.js +5 -1
  12. package/dist/api/index.js.map +1 -1
  13. package/dist/api/runtime/GMIChunkTransformer.d.ts +1 -1
  14. package/dist/api/runtime/GMIChunkTransformer.d.ts.map +1 -1
  15. package/dist/api/runtime/GMIChunkTransformer.js +7 -2
  16. package/dist/api/runtime/GMIChunkTransformer.js.map +1 -1
  17. package/dist/api/runtime/completionGateway.d.ts +98 -0
  18. package/dist/api/runtime/completionGateway.d.ts.map +1 -0
  19. package/dist/api/runtime/completionGateway.js +347 -0
  20. package/dist/api/runtime/completionGateway.js.map +1 -0
  21. package/dist/api/runtime/contextWindowFit.d.ts +49 -0
  22. package/dist/api/runtime/contextWindowFit.d.ts.map +1 -0
  23. package/dist/api/runtime/contextWindowFit.js +109 -0
  24. package/dist/api/runtime/contextWindowFit.js.map +1 -0
  25. package/dist/api/runtime/gatewayProviderManager.d.ts +37 -0
  26. package/dist/api/runtime/gatewayProviderManager.d.ts.map +1 -0
  27. package/dist/api/runtime/gatewayProviderManager.js +49 -0
  28. package/dist/api/runtime/gatewayProviderManager.js.map +1 -0
  29. package/dist/api/streamText.d.ts.map +1 -1
  30. package/dist/api/streamText.js +85 -4
  31. package/dist/api/streamText.js.map +1 -1
  32. package/dist/cognition/substrate/GMI.d.ts +21 -0
  33. package/dist/cognition/substrate/GMI.d.ts.map +1 -1
  34. package/dist/cognition/substrate/GMI.js +286 -88
  35. package/dist/cognition/substrate/GMI.js.map +1 -1
  36. package/dist/cognition/substrate/IGMI.d.ts +45 -1
  37. package/dist/cognition/substrate/IGMI.d.ts.map +1 -1
  38. package/dist/cognition/substrate/IGMI.js +4 -0
  39. package/dist/cognition/substrate/IGMI.js.map +1 -1
  40. package/dist/core/llm/providers/errors/ContextWindowExceededError.d.ts +22 -0
  41. package/dist/core/llm/providers/errors/ContextWindowExceededError.d.ts.map +1 -0
  42. package/dist/core/llm/providers/errors/ContextWindowExceededError.js +21 -0
  43. package/dist/core/llm/providers/errors/ContextWindowExceededError.js.map +1 -0
  44. package/dist/core/llm/routing/IModelRouter.d.ts +6 -0
  45. package/dist/core/llm/routing/IModelRouter.d.ts.map +1 -1
  46. package/dist/core/llm/routing/PolicyAwareRouter.d.ts +7 -0
  47. package/dist/core/llm/routing/PolicyAwareRouter.d.ts.map +1 -1
  48. package/dist/core/llm/routing/PolicyAwareRouter.js +12 -0
  49. package/dist/core/llm/routing/PolicyAwareRouter.js.map +1 -1
  50. package/dist/core/llm/routing/UncensoredModelCatalog.d.ts +37 -0
  51. package/dist/core/llm/routing/UncensoredModelCatalog.d.ts.map +1 -1
  52. package/dist/core/llm/routing/UncensoredModelCatalog.js +64 -74
  53. package/dist/core/llm/routing/UncensoredModelCatalog.js.map +1 -1
  54. package/dist/index.d.ts +5 -0
  55. package/dist/index.d.ts.map +1 -1
  56. package/dist/index.js +4 -0
  57. package/dist/index.js.map +1 -1
  58. package/dist/io/media/images/imageToBuffer.d.ts.map +1 -1
  59. package/dist/io/media/images/imageToBuffer.js +8 -1
  60. package/dist/io/media/images/imageToBuffer.js.map +1 -1
  61. package/package.json +1 -1
@@ -24,6 +24,9 @@ import { recordAgentOSTurnMetrics, withAgentOSSpan } from '../safety/evaluation/
24
24
  import { createLogger } from '../core/logging/loggerFactory.js';
25
25
  import { globalLLMProviderHealth } from '../core/safety/LLMProviderHealthRegistry.js';
26
26
  import { CONTEXT_WINDOW_EXCEEDED_CODE } from '../core/llm/providers/errors/errorCodes.js';
27
+ import { ContextWindowExceededError } from '../core/llm/providers/errors/ContextWindowExceededError.js';
28
+ import { catalogEntryHasCapability, createUncensoredModelCatalog, findCatalogTextModel, } from '../core/llm/routing/UncensoredModelCatalog.js';
29
+ import { checkContextFit } from './runtime/contextWindowFit.js';
27
30
  import { describeResponseFormatShape } from './runtime/responseFormatForProvider.js';
28
31
  const fallbackLogger = createLogger('fallback');
29
32
  /**
@@ -324,7 +327,7 @@ export function addModelUsage(target, usage) {
324
327
  * The usage a provider error reports for the request it ended, such as the
325
328
  * billed tokens of a refused turn (`details.usage`).
326
329
  *
327
- * @internal Shared with streamText.
330
+ * @internal Shared with streamText and the completion gateway.
328
331
  */
329
332
  export function usageOfError(error) {
330
333
  return error?.details?.usage;
@@ -662,81 +665,182 @@ export function fallbackHopOverrides(opts, entry) {
662
665
  };
663
666
  }
664
667
  /**
665
- * Build a policy-tier-aware fallback chain. Used by callers that pass
666
- * `policyTier: 'mature' | 'private-adult'` so refusals from the
667
- * primary model (typically gpt-4o, Claude, Gemini: all of which
668
- * moderate explicit content) re-route to an uncensored OpenRouter
669
- * model instead of hard-failing the request.
668
+ * The policy tier a call's fallback chain is built for: the explicit route
669
+ * tier, the call's tier, the host policy's tier (`standard` when a host
670
+ * policy names none), then the router's default. The primary's route block
671
+ * sends the first three terms only, so a delegated base router still sees no
672
+ * tier when no explicit source set one.
673
+ */
674
+ export function resolvePolicyTier(opts) {
675
+ return (opts.routerParams?.policyTier ??
676
+ opts.policyTier ??
677
+ hostPolicyToRouteParams(opts.hostPolicy).policyTier ??
678
+ opts.router?.policyTier);
679
+ }
680
+ /** @internal */
681
+ export const INITIAL_FALLBACK_WALK = Object.freeze({
682
+ refillsOwed: 0,
683
+ refusalSeen: false,
684
+ });
685
+ /** Claude, direct or through OpenRouter. */
686
+ function isClaudeEntry(entry) {
687
+ return (entry.provider === 'anthropic' ||
688
+ (entry.provider === 'openrouter' && (entry.model ?? '').startsWith('anthropic/')));
689
+ }
690
+ /**
691
+ * The capabilities a call names explicitly (host policy and route params).
692
+ * Native tool calling is not inferred from the presence of tools: a model
693
+ * without it serves a tool-carrying call through the prompt shim.
670
694
  *
671
- * Chain order for mature / private-adult:
672
- * 1. `nousresearch/hermes-3-llama-3.1-405b` on OpenRouter: leads
673
- * the uncensored leaderboard for instruction-following + long-
674
- * context comprehension. Same model the wilds-ai
675
- * companion-pipeline uses for identity generation on mature+
676
- * companions; battle-tested on real workloads.
677
- * 2. `anthropic/claude-sonnet-4` on OpenRouter: Claude refuses
678
- * hard NSFW but is markedly more permissive than gpt-4o for
679
- * narrative analysis of explicit fiction (extracting characters
680
- * from a CAI-export with mild adult content, etc.). Acts as the
681
- * headroom band when Hermes 3 is rate-limited or down.
682
- * 3. The standard {@link buildFallbackChain} suffix: keeps
683
- * availability fallback on top of the policy fallback so a
684
- * mature request that hits a Hermes 3 outage AND a Sonnet
685
- * outage still has gpt-4o-mini etc. to fall back to (which
686
- * will refuse on the explicit case but at least surfaces a
687
- * moderation error rather than a network error).
695
+ * @internal
696
+ */
697
+ export function explicitRequiredCapabilities(opts) {
698
+ return (mergeRequiredCapabilities(hostPolicyToRouteParams(opts.hostPolicy).requiredCapabilities, opts.routerParams?.requiredCapabilities) ?? []);
699
+ }
700
+ /**
701
+ * The provider and model a fallback entry is sent as: resolved the way the
702
+ * leg's own call resolves it (a provider's default model when the entry
703
+ * names none, a `provider:model` id split), or the entry as written when it
704
+ * does not resolve.
705
+ */
706
+ function fallbackEntrySentAs(entry) {
707
+ try {
708
+ const { providerId, modelId } = resolveModelOption({ provider: entry.provider, model: entry.model }, 'text');
709
+ return { provider: providerId, model: modelId };
710
+ }
711
+ catch {
712
+ return { provider: entry.provider, model: entry.model };
713
+ }
714
+ }
715
+ /**
716
+ * Resolve a call's fallback chain once, at the start of the top-level walk.
717
+ * Policy-chain entries (`origin: 'policy-default'`) naming the failed first
718
+ * model are dropped; every entry is checked, as the provider and model it
719
+ * will be sent as, against the call's explicitly required capabilities and
720
+ * excluded models (a model outside the catalog counts as capable); the
721
+ * uncensored group's first two remaining entries become standing legs and
722
+ * the rest refills. Caller-written entries keep their order.
688
723
  *
689
- * For `safe` / `standard` tiers, this is identical to
690
- * {@link buildFallbackChain}: no uncensored prefix needed. Callers
691
- * that don't pass a tier should keep using the original builder.
724
+ * @internal
725
+ */
726
+ export function resolveFallbackChain(chain, ctx) {
727
+ const required = ctx.requiredCapabilities ?? [];
728
+ const excluded = new Set(ctx.excludedModelIds ?? []);
729
+ const resolved = [];
730
+ let uncensoredLegs = 0;
731
+ for (const entry of chain) {
732
+ if (entry.origin === 'policy-default' &&
733
+ entry.provider === ctx.primary.provider &&
734
+ entry.model === ctx.primary.model) {
735
+ ctx.onSkip?.(entry, 'failed_primary');
736
+ continue;
737
+ }
738
+ const sentAs = fallbackEntrySentAs(entry);
739
+ if (sentAs.model !== undefined && excluded.has(sentAs.model)) {
740
+ ctx.onSkip?.(entry, 'excluded_model');
741
+ continue;
742
+ }
743
+ const catalogEntry = sentAs.model !== undefined ? findCatalogTextModel(sentAs.model, sentAs.provider) : undefined;
744
+ if (catalogEntry && required.some((capability) => !catalogEntryHasCapability(catalogEntry, capability))) {
745
+ ctx.onSkip?.(entry, 'missing_capability');
746
+ continue;
747
+ }
748
+ if (entry.origin === 'policy-default' && entry.group === 'uncensored') {
749
+ resolved.push({ ...entry, walkRole: uncensoredLegs < 2 ? 'standing' : 'refill' });
750
+ uncensoredLegs += 1;
751
+ }
752
+ else {
753
+ resolved.push(entry);
754
+ }
755
+ }
756
+ return resolved;
757
+ }
758
+ /**
759
+ * Whether the walk runs `entry` now: a refill only while a standing leg is
760
+ * owed one, and none of the policy chain's Claude legs after a refusal.
692
761
  *
693
- * Auto-built fallbacks always require their own env keys; missing
694
- * keys silently drop the entry rather than throwing, so a partial
695
- * deploy still produces a usable (shorter) chain.
762
+ * @internal
763
+ */
764
+ export function gateFallbackEntry(state, entry) {
765
+ if (state.refusalSeen && entry.origin === 'policy-default' && isClaudeEntry(entry)) {
766
+ return { run: false, reason: 'claude_after_refusal' };
767
+ }
768
+ if (entry.walkRole === 'refill') {
769
+ if (state.refillsOwed < 1)
770
+ return { run: false, reason: 'refill_not_owed' };
771
+ return { run: true, state: { ...state, refillsOwed: state.refillsOwed - 1 } };
772
+ }
773
+ return { run: true, state };
774
+ }
775
+ /**
776
+ * The walk's state after an attempt failed with `error`: a standing leg that
777
+ * failed for anything but a content decline (a timeout, an open breaker and
778
+ * the context check's refusal included) is owed a refill, and a model
779
+ * refusal (code `content_filter`; a filter's `content_policy_violation`
780
+ * does not count) is recorded for the rest of the walk.
781
+ *
782
+ * @internal
783
+ */
784
+ export function advanceFallbackWalk(state, role, error) {
785
+ const owed = role === 'standing' && !isContentPolicyRefusal(error) ? 1 : 0;
786
+ const refusal = error?.code === 'content_filter';
787
+ return {
788
+ refillsOwed: state.refillsOwed + owed,
789
+ refusalSeen: state.refusalSeen || refusal,
790
+ };
791
+ }
792
+ /** The catalog the policy chain reads its ladders from. */
793
+ const POLICY_CATALOG = createUncensoredModelCatalog();
794
+ /** A catalog model never used as a fallback leg: too weak for a rescue reply. */
795
+ const NEVER_A_LEG_MODEL = 'meta-llama/llama-3.1-8b-instruct';
796
+ /**
797
+ * Build a policy-tier-aware fallback chain, for callers that pass
798
+ * `policyTier: 'mature' | 'private-adult'`: a refusal or an outage of the
799
+ * primary walks to an uncensored model before the availability legs.
800
+ *
801
+ * Mature and private-adult: the tier's catalog ladder
802
+ * (the catalog's `getFallbackLadder`; private-adult keeps the
803
+ * models that permit `erotic`) as OpenRouter legs in the `uncensored`
804
+ * group, llama-3.1-8b left out, then the {@link buildFallbackChain} suffix.
805
+ * Every entry is tagged `origin: 'policy-default'`, which lets the walk drop
806
+ * the leg naming the failed first model, keep two standing uncensored legs,
807
+ * and pass over Claude legs after a refusal. Safe, standard and an absent
808
+ * tier get {@link buildFallbackChain} unchanged and untagged.
696
809
  *
697
- * @param tier - Caller's intended content tier. Mature/private-adult
698
- * triggers the uncensored prefix; safe/standard returns the
699
- * availability-only chain.
700
- * @param excludeProvider - Provider to omit (typically the primary
701
- * that already failed). Mirrors {@link buildFallbackChain}.
702
- * @returns Ordered fallback entries: uncensored prefix (when tier
703
- * warrants it) + availability suffix.
810
+ * Each leg pins `cache: false`. A missing key drops its legs instead of
811
+ * throwing, so a partial deploy still gets a shorter usable chain.
812
+ *
813
+ * @param tier - The call's content tier.
814
+ * @param excludeProvider - Provider to leave out of the availability suffix
815
+ * (typically the failed primary's). The ladder legs ignore it: another
816
+ * model on the same provider is a valid rescue, and the walk drops the
817
+ * exact failed model.
704
818
  */
705
819
  export function buildPolicyAwareFallbackChain(tier, excludeProvider) {
706
- const isMatureTier = tier === 'mature' || tier === 'private-adult';
707
- if (!isMatureTier) {
820
+ if (tier !== 'mature' && tier !== 'private-adult') {
708
821
  return buildFallbackChain(excludeProvider);
709
822
  }
710
823
  const chain = [];
711
- // Hermes 3 405B leads: uncensored, large, instruction-following
712
- // proven on the wilds-ai identity-generation path. Skipped when
713
- // OPENROUTER_API_KEY is absent rather than throwing; the suffix
714
- // chain may still produce a usable fallback.
715
824
  if (process.env.OPENROUTER_API_KEY) {
716
- chain.push({
717
- provider: 'openrouter',
718
- model: 'nousresearch/hermes-3-llama-3.1-405b',
719
- cache: false,
720
- });
721
- // Sonnet via OpenRouter as the second uncensored band. We keep
722
- // it on OpenRouter (not direct Anthropic) because the chain's
723
- // `excludeProvider` semantics treat each entry as a provider
724
- // ID: using `anthropic` here would lock out the suffix's
725
- // Anthropic fallback. OpenRouter routes Claude under its own
726
- // billing surface, so the slot is independent.
727
- chain.push({
728
- provider: 'openrouter',
729
- model: 'anthropic/claude-sonnet-4',
730
- cache: false,
731
- });
825
+ const ladder = tier === 'private-adult'
826
+ ? POLICY_CATALOG.getFallbackLadder('private-adult', { contentIntent: 'erotic' })
827
+ : POLICY_CATALOG.getFallbackLadder('mature');
828
+ for (const entry of ladder) {
829
+ if (entry.modelId === NEVER_A_LEG_MODEL)
830
+ continue;
831
+ chain.push({
832
+ provider: entry.providerId,
833
+ model: entry.modelId,
834
+ cache: false,
835
+ origin: 'policy-default',
836
+ group: 'uncensored',
837
+ });
838
+ }
732
839
  }
733
- // Append the standard availability chain. Filter out duplicates
734
- // since the uncensored prefix may have already added openrouter.
735
- const availability = buildFallbackChain(excludeProvider);
736
- for (const entry of availability) {
737
- const alreadyInChain = chain.some((existing) => existing.provider === entry.provider && existing.model === entry.model);
738
- if (!alreadyInChain)
739
- chain.push(entry);
840
+ for (const entry of buildFallbackChain(excludeProvider)) {
841
+ const listed = chain.some((e) => e.provider === entry.provider && e.model === entry.model);
842
+ if (!listed)
843
+ chain.push({ ...entry, origin: 'policy-default' });
740
844
  }
741
845
  return chain;
742
846
  }
@@ -947,6 +1051,28 @@ export async function generateText(opts) {
947
1051
  function: { name: t.name, description: t.description, parameters: t.inputSchema },
948
1052
  }))
949
1053
  : undefined;
1054
+ // A catalog model is sent only a request it can hold: otherwise the
1055
+ // call throws in place of the send and the walk moves on. Checked on
1056
+ // the first native send and on the prompt shim's first send.
1057
+ const assertFitsContextWindow = (sent) => {
1058
+ const fit = checkContextFit({
1059
+ provider: resolved.providerId,
1060
+ model: resolved.modelId,
1061
+ messages: sent.messages,
1062
+ tools: sent.tools,
1063
+ maxTokens: opts.maxTokens,
1064
+ customModelParams: opts.customModelParams,
1065
+ });
1066
+ if (!fit.fits && fit.contextWindow !== undefined) {
1067
+ throw new ContextWindowExceededError({
1068
+ provider: resolved.providerId,
1069
+ model: resolved.modelId,
1070
+ contextWindow: fit.contextWindow,
1071
+ estimatedInputTokens: fit.estimatedInputTokens,
1072
+ outputTokens: fit.outputTokens,
1073
+ });
1074
+ }
1075
+ };
950
1076
  const allToolCalls = [];
951
1077
  const totalUsage = attemptUsage;
952
1078
  // Provider-reported model id of the final step (spec batch-1 C1);
@@ -1003,6 +1129,7 @@ export async function generateText(opts) {
1003
1129
  // provider's tool-unsupported error (see the catch after the loop).
1004
1130
  const toolMode = opts.toolMode ?? 'auto';
1005
1131
  const shimMaxRoundtrips = opts.maxSteps ?? 5;
1132
+ let shimSendChecked = false;
1006
1133
  const runShim = async () => {
1007
1134
  const loopResult = await runEmulatedToolLoop({
1008
1135
  tools: Array.from(toolMap.values()),
@@ -1018,6 +1145,12 @@ export async function generateText(opts) {
1018
1145
  messages: toShimMessages(messages),
1019
1146
  maxRoundtrips: shimMaxRoundtrips,
1020
1147
  callModel: async (msgs) => {
1148
+ // The shim sends rendered tool text in place of native schemas,
1149
+ // so its first send is checked on its own.
1150
+ if (!shimSendChecked) {
1151
+ shimSendChecked = true;
1152
+ assertFitsContextWindow({ messages: msgs });
1153
+ }
1021
1154
  const r = await provider.generateCompletion(resolved.modelId, msgs, {
1022
1155
  temperature: opts.temperature,
1023
1156
  ...(opts.topP !== undefined ? { topP: opts.topP } : {}),
@@ -1176,6 +1309,10 @@ export async function generateText(opts) {
1176
1309
  console.warn('[agentos] onBeforeGeneration hook error:', hookErr);
1177
1310
  }
1178
1311
  }
1312
+ // A continuation leg's first send carries its completed tool rounds.
1313
+ // Later steps are not checked again.
1314
+ if (step === 0)
1315
+ assertFitsContextWindow({ messages: effectiveMessages, tools: toolSchemas });
1179
1316
  const response = await withAgentOSSpan('agentos.api.generate_text.step', async (stepSpan) => {
1180
1317
  stepSpan?.setAttribute('llm.provider', resolved.providerId);
1181
1318
  stepSpan?.setAttribute('llm.model', resolved.modelId);
@@ -1668,25 +1805,55 @@ export async function generateText(opts) {
1668
1805
  { provider: primaryProviderId ?? 'unknown', model: primaryModelId, ok: false },
1669
1806
  ];
1670
1807
  // ── Fallback chain ────────────────────────────────────────────────
1671
- // Resolve fallback chain: caller-supplied wins, undefined triggers
1672
- // auto-build from env keys, empty array explicitly opts out.
1673
- // When `policyTier` is mature/private-adult, the auto-build picks
1674
- // the policy-aware chain (Hermes 3 + Sonnet uncensored prefix +
1675
- // standard availability suffix) instead of the availability-only
1676
- // chain. Caller-supplied chains are respected verbatim regardless
1677
- // of tier: explicit beats implicit.
1678
- const effectiveFallbacks = opts.fallbackProviders === undefined
1679
- ? buildPolicyAwareFallbackChain(opts.policyTier, metricProviderId)
1808
+ // Caller-supplied wins, undefined auto-builds from env keys for the
1809
+ // call's resolved tier (the policy-aware chain on mature and
1810
+ // private-adult), an empty array opts out. The top-level walk resolves
1811
+ // the chain once: it drops the policy chain's entry for the failed first
1812
+ // model, applies the call's explicit capability and exclusion
1813
+ // requirements, and marks the uncensored group's standing legs and
1814
+ // refills. A leg receives its resolved slice and never resolves it again.
1815
+ const walkTier = resolvePolicyTier(opts);
1816
+ const chainEntries = opts.fallbackProviders === undefined
1817
+ ? buildPolicyAwareFallbackChain(walkTier, metricProviderId)
1680
1818
  : opts.fallbackProviders;
1819
+ const logLegSkip = (entry, reason) => fallbackLogger.info('provider fallback skipped', {
1820
+ event: 'fallback_leg_skipped',
1821
+ api: 'generateText',
1822
+ reason,
1823
+ primaryProvider: metricProviderId,
1824
+ fallbackProvider: entry.provider,
1825
+ fallbackModel: entry.model,
1826
+ });
1827
+ // Resolved only when the walk runs, so its skip lines describe a walk.
1681
1828
  // A call whose prompt-shim tools already ran cannot be continued, and a
1682
1829
  // restart would run them again; it surfaces the error instead.
1683
- if (effectiveFallbacks.length &&
1684
- isRetryableError(error) &&
1685
- !toolProgress.shimRanTool) {
1830
+ const walks = isRetryableError(error) && !toolProgress.shimRanTool;
1831
+ const effectiveFallbacks = !walks
1832
+ ? []
1833
+ : opts.__fallbackWalk
1834
+ ? chainEntries
1835
+ : resolveFallbackChain(chainEntries, {
1836
+ primary: { provider: metricProviderId, model: metricModelId },
1837
+ requiredCapabilities: explicitRequiredCapabilities(opts),
1838
+ excludedModelIds: opts.routerParams?.excludedModelIds,
1839
+ onSkip: logLegSkip,
1840
+ });
1841
+ // This attempt's own failure updates the walk: a refusal is recorded,
1842
+ // and a standing leg that failed on availability is owed a refill.
1843
+ let walkState = advanceFallbackWalk(opts.__fallbackWalk?.state ?? INITIAL_FALLBACK_WALK, opts.__fallbackWalk?.role, error);
1844
+ if (walks && effectiveFallbacks.length) {
1686
1845
  let lastError = error;
1687
1846
  let attempt = 0;
1688
1847
  for (const fb of effectiveFallbacks) {
1689
1848
  attempt += 1;
1849
+ // A refill runs only while a standing leg is owed one, and the
1850
+ // policy chain's Claude legs are passed over after a refusal.
1851
+ const gate = gateFallbackEntry(walkState, fb);
1852
+ if (!gate.run) {
1853
+ logLegSkip(fb, gate.reason);
1854
+ continue;
1855
+ }
1856
+ walkState = gate.state;
1690
1857
  // Skip fallback entries whose breaker is already open. Without
1691
1858
  // this check, the loop would still spend a full network round-
1692
1859
  // trip on every dead fallback in the chain before reaching the
@@ -1702,6 +1869,9 @@ export async function generateText(opts) {
1702
1869
  fallbackModel: fb.model,
1703
1870
  attempt,
1704
1871
  });
1872
+ // An open breaker is an availability failure: a standing leg is
1873
+ // owed a refill.
1874
+ walkState = advanceFallbackWalk(walkState, fb.walkRole, undefined);
1705
1875
  continue;
1706
1876
  }
1707
1877
  try {
@@ -1735,6 +1905,12 @@ export async function generateText(opts) {
1735
1905
  ...opts,
1736
1906
  provider: fb.provider,
1737
1907
  model: fb.model,
1908
+ // Legs run as named: no router re-picks the model, and the call's
1909
+ // resolved tier travels with them.
1910
+ router: undefined,
1911
+ policyTier: walkTier,
1912
+ // The walk's state before this leg, and its role.
1913
+ __fallbackWalk: { state: walkState, role: fb.walkRole },
1738
1914
  // When a builder exists, ALWAYS override _responseFormat —
1739
1915
  // including with an explicit `undefined` on builder failure or
1740
1916
  // decline. Merely omitting the key would let the `...opts`
@@ -1764,7 +1940,7 @@ export async function generateText(opts) {
1764
1940
  // fallback provider rather than the primary's overrides.
1765
1941
  apiKey: undefined,
1766
1942
  baseUrl: undefined,
1767
- // Preserve the REMAINING explicit chain (entries AFTER the current
1943
+ // Preserve the REMAINING resolved chain (entries AFTER the current
1768
1944
  // fb; `attempt` is 1-indexed so slice(attempt) drops fb and all
1769
1945
  // already-tried entries). This stops the recursion from rebuilding
1770
1946
  // the default cheap chain (which includes gpt-4o-mini) when a
@@ -1844,6 +2020,7 @@ export async function generateText(opts) {
1844
2020
  // The leg walked every entry after it and all of them failed.
1845
2021
  if (chainWalkedBefore(fbError))
1846
2022
  break;
2023
+ walkState = advanceFallbackWalk(walkState, fb.walkRole, fbError);
1847
2024
  }
1848
2025
  }
1849
2026
  // All fallbacks exhausted: fall through to throw