@oh-my-pi/pi-ai 16.1.17 → 16.1.19
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.
- package/CHANGELOG.md +28 -0
- package/dist/types/auth-storage.d.ts +40 -0
- package/dist/types/providers/openai-codex/request-transformer.d.ts +2 -0
- package/dist/types/providers/transform-messages.d.ts +1 -1
- package/package.json +4 -4
- package/src/auth-broker/refresher.ts +8 -8
- package/src/auth-storage.ts +258 -77
- package/src/providers/amazon-bedrock.ts +71 -6
- package/src/providers/ollama.ts +25 -17
- package/src/providers/openai-codex/request-transformer.ts +23 -2
- package/src/providers/openai-codex-responses.ts +2 -1
- package/src/providers/openai-completions.ts +12 -0
- package/src/providers/transform-messages.ts +151 -4
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,34 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [16.1.19] - 2026-06-25
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Fixed Ollama/llama.cpp chat payloads serializing user-attributed mid-conversation developer messages (auto-learn capture nudge, advisor cards, file-mention companions) as `system` turns; they now serialize as `user` so llama.cpp can reuse the warm prompt prefix instead of forcing full re-processing. Agent-owned developer reminders (`attribution: "agent"` — empty/unexpected-stop retries, checkpoint rewind warning, todo reminders) keep their `system` priority. ([#3456](https://github.com/can1357/oh-my-pi/issues/3456))
|
|
10
|
+
- Fixed prior-turn reasoning being lost on cross-API provider switches: when a session moved from an Anthropic-compatible 3p endpoint to an OpenAI-compatible one (Z.AI Anthropic → Z.AI OpenAI, Kimi Anthropic → Kimi OpenAI, DeepSeek, OpenCode-hosted reasoning models, or any custom `models.yaml` switch that crosses API types), the cross-API path of `transformMessages` text-demoted every prior `thinking` block, so the next request shipped the reasoning chain as plain conversation `content` instead of structured `reasoning_content` — losing it as reasoning context and re-billing it. `convertMessages` now threads the request-time resolved compat into `transformMessages`, which preserves the prior reasoning as a native, signature-stripped `thinking` block whenever that resolved target accepts `reasoning_content` as a continuation hint (`requiresReasoningContentForToolCalls` — including the `whenThinking` policy OpenCode reactivates for thinking-on requests, #1071/#1484 — or `thinkingFormat: "zai"`); the `openai-completions` encoder surfaces those blocks via `reasoningContentField`, with a new branch for Z.AI-format hosts (Z.AI, Zhipu, Moonshot Kimi, Xiaomi MiMo) that accept but don't require the field. Targets that can't replay unsigned reasoning (encrypted reasoning blobs, signed thought parts, non-reasoning models, thinking-disabled OpenCode) still text-demote so the reasoning survives as conversation context. ([#3437](https://github.com/can1357/oh-my-pi/pull/3437), [#3439](https://github.com/can1357/oh-my-pi/pull/3439) by [@roboomp](https://github.com/roboomp); [#3433](https://github.com/can1357/oh-my-pi/issues/3433), [#3434](https://github.com/can1357/oh-my-pi/issues/3434))
|
|
11
|
+
- Fixed Bedrock cross-region inference profiles routing to `us-east-1` regardless of their geo prefix: a profile such as `eu.anthropic.claude-…` (or `apac.`/`au.`/`jp.`) sent to the hardcoded `us-east-1` endpoint returned HTTP 400 `The provided model identifier is invalid`. `streamBedrock` now derives the runtime region from the profile's geo prefix — honoring an ambient `AWS_REGION`/`AWS_DEFAULT_REGION` only when it can serve that geo and falling back to the geo's default region otherwise — while explicit per-request and ARN-embedded regions still win and region-agnostic `global.` profiles stay unchanged.
|
|
12
|
+
- Fixed malformed tool calls (empty `name`) wedging entire sessions in HTTP 400 loops: when a model occasionally emits `{ "name": "", "arguments": "{}" }` (observed: GLM-5.2 + thinking on long turns), the agent rejected the call at execution time with `Tool not found`, but the malformed block plus its error `toolResult` stayed in conversation history and every subsequent request 400'd on `tool_use.name`/`tool_calls[i].function.name` validation until the user ran `/clear`. `transformMessages` — the canonical sanitize boundary every provider passes through — now drops `toolCall` blocks with empty/whitespace `name`, pairs them with their `toolResult` messages only inside the same assistant→tool-result window (per-id FIFO queue cleared at non-result boundaries, so stale malformed calls without a result cannot consume later valid duplicate-id outputs), and drops the assistant turn when it has no replayable content left. Defensive (provider-agnostic, fires regardless of model), idempotent (no-op on a clean history), and self-healing (one round-trip after the fix lands sanitizes an already-poisoned session). ([#3458](https://github.com/can1357/oh-my-pi/issues/3458))
|
|
13
|
+
|
|
14
|
+
## [16.1.18] - 2026-06-25
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- Added `listOAuthAccounts` for retrieving a read-only list of stored OAuth account identities
|
|
19
|
+
- Added `getOAuthAccessAt` to resolve an OAuth token exclusively for a specific account position
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- Refactored OAuth token persistence and disable logic to use stable credential IDs instead of positional indices to prevent race conditions during concurrent updates
|
|
24
|
+
- Updated OAuth failure classification to treat 403 status codes, rate limits, and network errors as transient, preventing unnecessary credential invalidation
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- Fixed Codex Responses Lite staying enabled for image prompts, which caused GPT/Codex image turns to be rejected as `Invalid value: 'input_image'`; image-bearing Codex requests now fall back to the full Responses transport. ([#3421](https://github.com/can1357/oh-my-pi/issues/3421))
|
|
29
|
+
- Fixed the auth-broker background refresher disabling OAuth credentials unconditionally (`disableCredentialById`) on a definitive refresh failure, so a credential another process or a fresh login rotated mid-refresh could be torn down even though the stored row already held a valid token. The definitive-failure teardown now happens inside `AuthStorage.refreshCredentialById` via the same compare-and-set the in-stream and usage-probe paths use — it disables only when the persisted row still matches the credential the refresh actually attempted, and reloads on a CAS loss; the refresher now only logs.
|
|
30
|
+
- Fixed OAuth refresh persisting the rotated token by a positional index captured before the refresh `await`. A concurrent disable could reorder or shrink a provider's credential array while the refresh was in flight, landing the new token on the wrong row (or silently dropping it) and leaving accounts with a stale refresh token that failed — and was then disabled — on the next cycle. Refresh persistence, selection-index resync, and CAS-disable now address the row by id across `forceRefreshCredentialById`, candidate preflight, and in-stream selection (`#replaceCredentialById` / `#disableCredentialByIdIfMatches`).
|
|
31
|
+
- Fixed `isDefinitiveOAuthFailure` treating a bare HTTP 403 (and generic `unauthorized` / access-token-expired wording) as a definitive credential failure, which permanently disabled healthy OAuth accounts on WAF, egress rate-limit, permission, and account-verification responses. Bare 403, rate limits (429), gateway/5xx, and more network errors (`ECONNRESET`, `ETIMEDOUT`, `EAI_AGAIN`, …) are now classified transient; only explicit dead-grant errors (`invalid_grant`, `invalid_token`, `unauthorized_client`, revoked, `refresh token … expired`) or a bare 401 tear the credential down.
|
|
32
|
+
|
|
5
33
|
## [16.1.17] - 2026-06-24
|
|
6
34
|
|
|
7
35
|
### Added
|
|
@@ -467,6 +467,19 @@ export type OAuthAccessResolution = ({
|
|
|
467
467
|
} & OAuthAccess) | ({
|
|
468
468
|
ok: false;
|
|
469
469
|
} & OAuthAccessFailure);
|
|
470
|
+
/**
|
|
471
|
+
* Read-only identity of one stored OAuth account, in stable storage order.
|
|
472
|
+
* Returned by {@link AuthStorage.listOAuthAccounts}; `position` (0-based) is the
|
|
473
|
+
* selector accepted by {@link AuthStorage.getOAuthAccessAt}.
|
|
474
|
+
*/
|
|
475
|
+
export interface OAuthAccountSummary {
|
|
476
|
+
position: number;
|
|
477
|
+
credentialId: number;
|
|
478
|
+
accountId?: string;
|
|
479
|
+
email?: string;
|
|
480
|
+
projectId?: string;
|
|
481
|
+
enterpriseUrl?: string;
|
|
482
|
+
}
|
|
470
483
|
export interface InvalidateCredentialMatchingOptions {
|
|
471
484
|
signal?: AbortSignal;
|
|
472
485
|
sessionId?: string;
|
|
@@ -703,6 +716,14 @@ export declare class AuthStorage {
|
|
|
703
716
|
sessionId?: string;
|
|
704
717
|
baseUrl?: string;
|
|
705
718
|
}): boolean;
|
|
719
|
+
/**
|
|
720
|
+
* The {@link UsageProvider} registered for `provider`, or undefined when the
|
|
721
|
+
* provider has no usage endpoint at all. Lets callers tell "a credential we
|
|
722
|
+
* could have fetched usage for but didn't" apart from "a provider with no
|
|
723
|
+
* usage concept" (web-search keys, local/keyless servers, inference
|
|
724
|
+
* providers without a usage API) — the latter never warrants a usage row.
|
|
725
|
+
*/
|
|
726
|
+
usageProviderFor(provider: Provider): UsageProvider | undefined;
|
|
706
727
|
fetchUsageReports(options?: {
|
|
707
728
|
baseUrlResolver?: (provider: Provider) => string | undefined;
|
|
708
729
|
/** Caller's cancel signal; only rejects this caller, never the shared upstream fetch. */
|
|
@@ -782,6 +803,13 @@ export declare class AuthStorage {
|
|
|
782
803
|
* OAuth with an explicit API key.
|
|
783
804
|
*/
|
|
784
805
|
getOAuthAccess(provider: string, sessionId?: string, options?: AuthApiKeyOptions): Promise<OAuthAccess | undefined>;
|
|
806
|
+
/**
|
|
807
|
+
* Read-only list of stored OAuth accounts for `provider` in stable storage
|
|
808
|
+
* order, WITHOUT refreshing any token. The array position (0-based) is the
|
|
809
|
+
* selector accepted by {@link AuthStorage.getOAuthAccessAt}; a "pick the Nth
|
|
810
|
+
* account" UI should render `position + 1`.
|
|
811
|
+
*/
|
|
812
|
+
listOAuthAccounts(provider: string): OAuthAccountSummary[];
|
|
785
813
|
/**
|
|
786
814
|
* Resolve every stored OAuth credential for `provider` independently.
|
|
787
815
|
*
|
|
@@ -791,6 +819,18 @@ export declare class AuthStorage {
|
|
|
791
819
|
* exercise each stored account exactly once.
|
|
792
820
|
*/
|
|
793
821
|
getOAuthAccesses(provider: string, options?: AuthApiKeyOptions): Promise<OAuthAccessResolution[]>;
|
|
822
|
+
/**
|
|
823
|
+
* Resolve a single stored OAuth credential by its account position (0-based,
|
|
824
|
+
* matching {@link AuthStorage.listOAuthAccounts}). Refreshes ONLY that
|
|
825
|
+
* credential ({@link #resolveStoredOAuthAccess} runs with `allowFallback:
|
|
826
|
+
* false`), so — unlike {@link AuthStorage.getOAuthAccesses} — a definitive
|
|
827
|
+
* failure of the targeted account surfaces as a failed resolution rather than
|
|
828
|
+
* silently rotating or rate-tripping a sibling.
|
|
829
|
+
*
|
|
830
|
+
* Returns `undefined` when `position` is out of range or runtime/config
|
|
831
|
+
* overrides have replaced OAuth with an explicit API key.
|
|
832
|
+
*/
|
|
833
|
+
getOAuthAccessAt(provider: string, position: number, options?: AuthApiKeyOptions): Promise<OAuthAccessResolution | undefined>;
|
|
794
834
|
/**
|
|
795
835
|
* List saved rate-limit resets for every stored OAuth account of `provider`
|
|
796
836
|
* (Codex), fetched LIVE from the dedicated `rate-limit-reset-credits` route.
|
|
@@ -47,6 +47,8 @@ export interface RequestBody {
|
|
|
47
47
|
service_tier?: "auto" | "default" | "flex" | "scale" | "priority" | null;
|
|
48
48
|
[key: string]: unknown;
|
|
49
49
|
}
|
|
50
|
+
/** Returns whether a Codex request can use the text-only Responses Lite transport. */
|
|
51
|
+
export declare function shouldUseCodexResponsesLite(body: RequestBody, requested: boolean | undefined): boolean;
|
|
50
52
|
export declare function transformRequestBody(body: RequestBody, model: Model<Api>, options?: CodexRequestOptions, prompt?: {
|
|
51
53
|
developerMessages: string[];
|
|
52
54
|
}): Promise<RequestBody>;
|
|
@@ -8,4 +8,4 @@ import type { Api, AssistantMessage, Message, Model } from "../types";
|
|
|
8
8
|
* - Preserves tool call structure (unlike converting to text summaries)
|
|
9
9
|
* - Injects synthetic "aborted" tool results
|
|
10
10
|
*/
|
|
11
|
-
export declare function transformMessages<TApi extends Api>(messages: Message[], model: Model<TApi>, normalizeToolCallId?: (id: string, model: Model<TApi>, source: AssistantMessage) => string, maxNormalizedToolCallIdLength?: number, duplicateToolCallIdSuffixPrefix?: string): Message[];
|
|
11
|
+
export declare function transformMessages<TApi extends Api>(messages: Message[], model: Model<TApi>, normalizeToolCallId?: (id: string, model: Model<TApi>, source: AssistantMessage) => string, maxNormalizedToolCallIdLength?: number, duplicateToolCallIdSuffixPrefix?: string, targetCompat?: Model<TApi>["compat"]): Message[];
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-ai",
|
|
4
|
-
"version": "16.1.
|
|
4
|
+
"version": "16.1.19",
|
|
5
5
|
"description": "Unified LLM API with automatic model discovery and provider configuration",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -38,9 +38,9 @@
|
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
40
|
"@bufbuild/protobuf": "^2.12.0",
|
|
41
|
-
"@oh-my-pi/pi-catalog": "16.1.
|
|
42
|
-
"@oh-my-pi/pi-utils": "16.1.
|
|
43
|
-
"@oh-my-pi/pi-wire": "16.1.
|
|
41
|
+
"@oh-my-pi/pi-catalog": "16.1.19",
|
|
42
|
+
"@oh-my-pi/pi-utils": "16.1.19",
|
|
43
|
+
"@oh-my-pi/pi-wire": "16.1.19",
|
|
44
44
|
"arktype": "^2.2.0",
|
|
45
45
|
"zod": "^4"
|
|
46
46
|
},
|
|
@@ -5,9 +5,10 @@
|
|
|
5
5
|
* any whose `expires - Date.now() < refreshSkewMs`. Refresh single-flight
|
|
6
6
|
* lives in {@link AuthStorage} so manual and background refreshes share the
|
|
7
7
|
* same upstream attempt.
|
|
8
|
-
* Definitively-failed credentials (invalid_grant / 401 not
|
|
9
|
-
* are
|
|
10
|
-
*
|
|
8
|
+
* Definitively-failed credentials (invalid_grant / bare 401, not a network
|
|
9
|
+
* blip) are torn down inside {@link AuthStorage.refreshCredentialById} via a
|
|
10
|
+
* compare-and-set disable — only when no peer/login rotated the row first — so
|
|
11
|
+
* the next snapshot pull surfaces a clean delete on the client.
|
|
11
12
|
*/
|
|
12
13
|
import { logger } from "@oh-my-pi/pi-utils";
|
|
13
14
|
import { type AuthStorage, isDefinitiveOAuthFailure } from "../auth-storage";
|
|
@@ -104,11 +105,10 @@ export class AuthBrokerRefresher {
|
|
|
104
105
|
} catch (error) {
|
|
105
106
|
const errorMsg = String(error);
|
|
106
107
|
if (isDefinitiveOAuthFailure(errorMsg)) {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
});
|
|
111
|
-
this.#storage.disableCredentialById(id, `auth-broker refresh failed: ${errorMsg}`);
|
|
108
|
+
// AuthStorage.refreshCredentialById already CAS-disabled the row
|
|
109
|
+
// (unless a peer/login rotated it first, in which case the live
|
|
110
|
+
// credential is intentionally kept). Nothing to do here but record it.
|
|
111
|
+
logger.warn("auth-broker refresh failed definitively", { id, error: errorMsg });
|
|
112
112
|
} else {
|
|
113
113
|
logger.debug("auth-broker refresh failed (transient)", { id, error: errorMsg });
|
|
114
114
|
}
|
package/src/auth-storage.ts
CHANGED
|
@@ -568,9 +568,17 @@ const MAX_PENDING_DISABLED_EVENTS = 32;
|
|
|
568
568
|
* while streaming requests correctly tear the row down.
|
|
569
569
|
*/
|
|
570
570
|
const OAUTH_DEFINITIVE_FAILURE_REGEX =
|
|
571
|
-
/invalid_grant|invalid_token|
|
|
572
|
-
|
|
573
|
-
|
|
571
|
+
/invalid_grant|invalid_token|unauthorized_client|\brevoked\b|refresh[\s_]?token.*expired/i;
|
|
572
|
+
// Transient: network blips, rate limits, gateway/5xx, and infra denials
|
|
573
|
+
// (WAF / egress 403, permission / account-verification) — block-and-retry,
|
|
574
|
+
// never tear the credential down for these.
|
|
575
|
+
const OAUTH_TRANSIENT_FAILURE_REGEX =
|
|
576
|
+
/timeout|network|fetch failed|ECONN(?:REFUSED|RESET)|ETIMEDOUT|EAI_AGAIN|socket hang up|\b(?:408|425|429|5\d{2})\b|rate.?limit|too many requests|temporar|unavailable|forbidden|permission_denied|cloudflare|captcha/i;
|
|
577
|
+
// A bare 401 from an OAuth token endpoint means the stored grant/client is
|
|
578
|
+
// dead. 403 is deliberately excluded: it is overwhelmingly WAF / egress
|
|
579
|
+
// rate-limit / permission / account-verification — none of which mean the
|
|
580
|
+
// refresh token itself is invalid.
|
|
581
|
+
const OAUTH_HTTP_AUTH_REGEX = /\b401\b/;
|
|
574
582
|
|
|
575
583
|
export function isDefinitiveOAuthFailure(errorMsg: string): boolean {
|
|
576
584
|
if (OAUTH_DEFINITIVE_FAILURE_REGEX.test(errorMsg)) return true;
|
|
@@ -673,6 +681,20 @@ export interface OAuthAccountIdentity {
|
|
|
673
681
|
}
|
|
674
682
|
|
|
675
683
|
export type OAuthAccessResolution = ({ ok: true } & OAuthAccess) | ({ ok: false } & OAuthAccessFailure);
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* Read-only identity of one stored OAuth account, in stable storage order.
|
|
687
|
+
* Returned by {@link AuthStorage.listOAuthAccounts}; `position` (0-based) is the
|
|
688
|
+
* selector accepted by {@link AuthStorage.getOAuthAccessAt}.
|
|
689
|
+
*/
|
|
690
|
+
export interface OAuthAccountSummary {
|
|
691
|
+
position: number;
|
|
692
|
+
credentialId: number;
|
|
693
|
+
accountId?: string;
|
|
694
|
+
email?: string;
|
|
695
|
+
projectId?: string;
|
|
696
|
+
enterpriseUrl?: string;
|
|
697
|
+
}
|
|
676
698
|
export interface InvalidateCredentialMatchingOptions {
|
|
677
699
|
signal?: AbortSignal;
|
|
678
700
|
sessionId?: string;
|
|
@@ -887,6 +909,7 @@ class AuthStorageUsageCache implements UsageCache {
|
|
|
887
909
|
|
|
888
910
|
type StoredCredential = { id: number; credential: AuthCredential };
|
|
889
911
|
type OAuthSelection = { credential: OAuthCredential; index: number };
|
|
912
|
+
type StoredOAuthSelection = { credentialId: number; credential: OAuthCredential; index: number };
|
|
890
913
|
|
|
891
914
|
type OAuthCandidate = {
|
|
892
915
|
selection: OAuthSelection;
|
|
@@ -1519,6 +1542,42 @@ export class AuthStorage {
|
|
|
1519
1542
|
return true;
|
|
1520
1543
|
}
|
|
1521
1544
|
|
|
1545
|
+
/**
|
|
1546
|
+
* Persist a refreshed credential addressed by id, not a positional index.
|
|
1547
|
+
* A concurrent disable can reorder/shrink the provider's row array while an
|
|
1548
|
+
* async refresh is in flight, so a pre-await index is unsafe; resolving the
|
|
1549
|
+
* row by id at write time lands the rotated token on the correct row. Returns
|
|
1550
|
+
* the row's current index, or -1 when it was disabled/removed mid-refresh.
|
|
1551
|
+
*/
|
|
1552
|
+
#replaceCredentialById(provider: string, id: number, credential: AuthCredential): number {
|
|
1553
|
+
const entries = this.#getStoredCredentials(provider);
|
|
1554
|
+
const index = entries.findIndex(entry => entry.id === id);
|
|
1555
|
+
if (index === -1) return -1;
|
|
1556
|
+
this.#store.updateAuthCredential(id, credential);
|
|
1557
|
+
const updated = [...entries];
|
|
1558
|
+
updated[index] = { id, credential };
|
|
1559
|
+
this.#setStoredCredentials(provider, updated);
|
|
1560
|
+
return index;
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
/**
|
|
1564
|
+
* CAS-disable the row with `id`, but only if its persisted credential still
|
|
1565
|
+
* matches `expected` — i.e. no peer/login rotated it while we refreshed.
|
|
1566
|
+
* Addresses the row by id (re-resolved here, then matched on `data` in the
|
|
1567
|
+
* store) so a concurrent reorder can't tear down the wrong credential.
|
|
1568
|
+
*/
|
|
1569
|
+
#disableCredentialByIdIfMatches(
|
|
1570
|
+
provider: string,
|
|
1571
|
+
id: number,
|
|
1572
|
+
expected: AuthCredential,
|
|
1573
|
+
disabledCause: string,
|
|
1574
|
+
): boolean {
|
|
1575
|
+
const entries = this.#getStoredCredentials(provider);
|
|
1576
|
+
const index = entries.findIndex(entry => entry.id === id);
|
|
1577
|
+
if (index === -1) return false;
|
|
1578
|
+
return this.#tryDisableCredentialAtIfMatches(provider, index, expected, disabledCause);
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1522
1581
|
#emitCredentialDisabled(event: CredentialDisabledEvent): void {
|
|
1523
1582
|
if (this.#credentialDisabledListeners.size === 0) {
|
|
1524
1583
|
// No subscribers — buffer for later replay. Cap the backlog so a process that runs
|
|
@@ -2601,6 +2660,17 @@ export class AuthStorage {
|
|
|
2601
2660
|
);
|
|
2602
2661
|
}
|
|
2603
2662
|
|
|
2663
|
+
/**
|
|
2664
|
+
* The {@link UsageProvider} registered for `provider`, or undefined when the
|
|
2665
|
+
* provider has no usage endpoint at all. Lets callers tell "a credential we
|
|
2666
|
+
* could have fetched usage for but didn't" apart from "a provider with no
|
|
2667
|
+
* usage concept" (web-search keys, local/keyless servers, inference
|
|
2668
|
+
* providers without a usage API) — the latter never warrants a usage row.
|
|
2669
|
+
*/
|
|
2670
|
+
usageProviderFor(provider: Provider): UsageProvider | undefined {
|
|
2671
|
+
return this.#usageProviderResolver?.(provider);
|
|
2672
|
+
}
|
|
2673
|
+
|
|
2604
2674
|
async fetchUsageReports(options?: {
|
|
2605
2675
|
baseUrlResolver?: (provider: Provider) => string | undefined;
|
|
2606
2676
|
/** Caller's cancel signal; only rejects this caller, never the shared upstream fetch. */
|
|
@@ -3278,7 +3348,12 @@ export class AuthStorage {
|
|
|
3278
3348
|
type: "oauth",
|
|
3279
3349
|
};
|
|
3280
3350
|
candidate.selection.credential = updated;
|
|
3281
|
-
|
|
3351
|
+
if (credentialId !== undefined) {
|
|
3352
|
+
const idx = this.#replaceCredentialById(provider, credentialId, updated);
|
|
3353
|
+
if (idx !== -1) candidate.selection.index = idx;
|
|
3354
|
+
} else {
|
|
3355
|
+
this.#replaceCredentialAt(provider, candidate.selection.index, updated);
|
|
3356
|
+
}
|
|
3282
3357
|
} catch (error) {
|
|
3283
3358
|
// Recovery for definitive failures (incl. peer rotation) lives in
|
|
3284
3359
|
// #tryOAuthCredential; log instead of swallowing silently — a bare
|
|
@@ -3460,6 +3535,8 @@ export class AuthStorage {
|
|
|
3460
3535
|
strategy?: CredentialRankingStrategy;
|
|
3461
3536
|
rankingContext?: CredentialRankingContext;
|
|
3462
3537
|
blockScope?: string;
|
|
3538
|
+
/** When false, a definitive failure of THIS credential returns undefined instead of falling back to the ranked/round-robin selector (target-only resolution). */
|
|
3539
|
+
allowFallback?: boolean;
|
|
3463
3540
|
},
|
|
3464
3541
|
): Promise<OAuthResolutionResult | undefined> {
|
|
3465
3542
|
const {
|
|
@@ -3471,6 +3548,7 @@ export class AuthStorage {
|
|
|
3471
3548
|
strategy,
|
|
3472
3549
|
rankingContext,
|
|
3473
3550
|
blockScope,
|
|
3551
|
+
allowFallback = true,
|
|
3474
3552
|
} = usageOptions;
|
|
3475
3553
|
if (!allowBlocked && this.#isCredentialBlocked(providerKey, selection.index, blockScope)) {
|
|
3476
3554
|
return undefined;
|
|
@@ -3479,6 +3557,11 @@ export class AuthStorage {
|
|
|
3479
3557
|
if (!(await this.#prepareOAuthCredentialForRequest(provider, selection, options))) {
|
|
3480
3558
|
return undefined;
|
|
3481
3559
|
}
|
|
3560
|
+
// Capture the row id once, immediately after #prepareOAuthCredentialForRequest
|
|
3561
|
+
// resynced selection.index from the store. A concurrent disable during the
|
|
3562
|
+
// usage/refresh awaits below can shift positional indices, so every later
|
|
3563
|
+
// refresh / persist / CAS-disable addresses the row by this stable id.
|
|
3564
|
+
const credentialId = this.#getStoredCredentials(provider)[selection.index]?.id;
|
|
3482
3565
|
|
|
3483
3566
|
const requiresProModel = requiresOpenAICodexProModel(provider, options?.modelId);
|
|
3484
3567
|
const applyProFilter = enforceProRequirement ?? requiresProModel;
|
|
@@ -3521,7 +3604,7 @@ export class AuthStorage {
|
|
|
3521
3604
|
const refreshedCredentials = await this.#refreshOAuthCredential(
|
|
3522
3605
|
provider,
|
|
3523
3606
|
selection.credential,
|
|
3524
|
-
|
|
3607
|
+
credentialId,
|
|
3525
3608
|
options?.signal,
|
|
3526
3609
|
);
|
|
3527
3610
|
const apiKey = customProvider.getApiKey
|
|
@@ -3537,7 +3620,7 @@ export class AuthStorage {
|
|
|
3537
3620
|
const refreshedCredentials = await this.#refreshOAuthCredential(
|
|
3538
3621
|
provider,
|
|
3539
3622
|
selection.credential,
|
|
3540
|
-
|
|
3623
|
+
credentialId,
|
|
3541
3624
|
options?.signal,
|
|
3542
3625
|
);
|
|
3543
3626
|
const oauthCreds: Record<string, OAuthCredentials> = {
|
|
@@ -3557,7 +3640,12 @@ export class AuthStorage {
|
|
|
3557
3640
|
enterpriseUrl: result.newCredentials.enterpriseUrl ?? selection.credential.enterpriseUrl,
|
|
3558
3641
|
apiEndpoint: result.newCredentials.apiEndpoint ?? selection.credential.apiEndpoint,
|
|
3559
3642
|
};
|
|
3560
|
-
|
|
3643
|
+
if (credentialId !== undefined) {
|
|
3644
|
+
const idx = this.#replaceCredentialById(provider, credentialId, updated);
|
|
3645
|
+
if (idx !== -1) selection.index = idx;
|
|
3646
|
+
} else {
|
|
3647
|
+
this.#replaceCredentialAt(provider, selection.index, updated);
|
|
3648
|
+
}
|
|
3561
3649
|
if ((checkUsage && !allowBlocked) || requiresProModel) {
|
|
3562
3650
|
const sameAccount = selection.credential.accountId === updated.accountId;
|
|
3563
3651
|
if (!usageChecked || !sameAccount) {
|
|
@@ -3607,7 +3695,6 @@ export class AuthStorage {
|
|
|
3607
3695
|
// refresh token has changed, the peer rotation succeeded and we should pick
|
|
3608
3696
|
// up the new credential instead of soft-deleting the row that the peer just
|
|
3609
3697
|
// updated.
|
|
3610
|
-
const credentialId = this.#getStoredCredentials(provider)[selection.index]?.id;
|
|
3611
3698
|
if (credentialId !== undefined) {
|
|
3612
3699
|
const latestRow = this.#store.listAuthCredentials(provider).find(row => row.id === credentialId);
|
|
3613
3700
|
const latestCredential = latestRow?.credential;
|
|
@@ -3618,29 +3705,37 @@ export class AuthStorage {
|
|
|
3618
3705
|
credentialId,
|
|
3619
3706
|
});
|
|
3620
3707
|
await this.reload();
|
|
3621
|
-
return this.#resolveOAuthSelection(provider, sessionId, options);
|
|
3708
|
+
if (allowFallback) return this.#resolveOAuthSelection(provider, sessionId, options);
|
|
3622
3709
|
}
|
|
3623
3710
|
}
|
|
3624
3711
|
// Permanently disable invalid credentials with an explicit cause for inspection/debugging.
|
|
3625
3712
|
// Use a CAS-style disable conditioned on the row still containing the stale credential
|
|
3626
3713
|
// we tried to refresh, so a peer rotation that lands between the pre-check above and
|
|
3627
3714
|
// this disable doesn't soft-delete the freshly-rotated row.
|
|
3628
|
-
const disabled =
|
|
3629
|
-
|
|
3630
|
-
|
|
3631
|
-
|
|
3632
|
-
|
|
3633
|
-
|
|
3715
|
+
const disabled =
|
|
3716
|
+
credentialId !== undefined
|
|
3717
|
+
? this.#disableCredentialByIdIfMatches(
|
|
3718
|
+
provider,
|
|
3719
|
+
credentialId,
|
|
3720
|
+
selection.credential,
|
|
3721
|
+
`oauth refresh failed: ${errorMsg}`,
|
|
3722
|
+
)
|
|
3723
|
+
: this.#tryDisableCredentialAtIfMatches(
|
|
3724
|
+
provider,
|
|
3725
|
+
selection.index,
|
|
3726
|
+
selection.credential,
|
|
3727
|
+
`oauth refresh failed: ${errorMsg}`,
|
|
3728
|
+
);
|
|
3634
3729
|
if (!disabled) {
|
|
3635
3730
|
logger.debug("OAuth refresh disable lost CAS; reloading after peer rotation", {
|
|
3636
3731
|
provider,
|
|
3637
3732
|
index: selection.index,
|
|
3638
3733
|
});
|
|
3639
3734
|
await this.reload();
|
|
3640
|
-
return this.#resolveOAuthSelection(provider, sessionId, options);
|
|
3735
|
+
if (allowFallback) return this.#resolveOAuthSelection(provider, sessionId, options);
|
|
3641
3736
|
}
|
|
3642
3737
|
if (this.#getCredentialsForProvider(provider).some(credential => credential.type === "oauth")) {
|
|
3643
|
-
return this.#resolveOAuthSelection(provider, sessionId, options);
|
|
3738
|
+
if (allowFallback) return this.#resolveOAuthSelection(provider, sessionId, options);
|
|
3644
3739
|
}
|
|
3645
3740
|
} else {
|
|
3646
3741
|
// Block temporarily for transient failures (5 minutes)
|
|
@@ -3784,6 +3879,83 @@ export class AuthStorage {
|
|
|
3784
3879
|
};
|
|
3785
3880
|
}
|
|
3786
3881
|
|
|
3882
|
+
/** Stored OAuth credentials for `provider` in stable order, paired with their full-list index and row id. */
|
|
3883
|
+
#getStoredOAuthSelections(provider: string): StoredOAuthSelection[] {
|
|
3884
|
+
return this.#getStoredCredentials(provider)
|
|
3885
|
+
.map((entry, index) => ({ credentialId: entry.id, credential: entry.credential, index }))
|
|
3886
|
+
.filter((entry): entry is StoredOAuthSelection => entry.credential.type === "oauth");
|
|
3887
|
+
}
|
|
3888
|
+
|
|
3889
|
+
/** Refresh one stored OAuth selection and shape it as an {@link OAuthAccessResolution}. */
|
|
3890
|
+
async #resolveStoredOAuthAccess(
|
|
3891
|
+
provider: string,
|
|
3892
|
+
selection: StoredOAuthSelection,
|
|
3893
|
+
providerKey: string,
|
|
3894
|
+
options: AuthApiKeyOptions | undefined,
|
|
3895
|
+
): Promise<OAuthAccessResolution> {
|
|
3896
|
+
try {
|
|
3897
|
+
const resolved = await this.#tryOAuthCredential(
|
|
3898
|
+
provider,
|
|
3899
|
+
{ credential: selection.credential, index: selection.index },
|
|
3900
|
+
providerKey,
|
|
3901
|
+
undefined,
|
|
3902
|
+
options,
|
|
3903
|
+
{ checkUsage: false, allowBlocked: true, allowFallback: false },
|
|
3904
|
+
);
|
|
3905
|
+
if (!resolved) {
|
|
3906
|
+
return {
|
|
3907
|
+
ok: false,
|
|
3908
|
+
credentialId: selection.credentialId,
|
|
3909
|
+
accountId: selection.credential.accountId,
|
|
3910
|
+
email: selection.credential.email,
|
|
3911
|
+
projectId: selection.credential.projectId,
|
|
3912
|
+
enterpriseUrl: selection.credential.enterpriseUrl,
|
|
3913
|
+
error: "OAuth access unavailable",
|
|
3914
|
+
};
|
|
3915
|
+
}
|
|
3916
|
+
const { credential } = resolved;
|
|
3917
|
+
return {
|
|
3918
|
+
ok: true,
|
|
3919
|
+
credentialId: selection.credentialId,
|
|
3920
|
+
accessToken: credential.access,
|
|
3921
|
+
accountId: credential.accountId,
|
|
3922
|
+
email: credential.email,
|
|
3923
|
+
projectId: credential.projectId,
|
|
3924
|
+
enterpriseUrl: credential.enterpriseUrl,
|
|
3925
|
+
};
|
|
3926
|
+
} catch (error) {
|
|
3927
|
+
return {
|
|
3928
|
+
ok: false,
|
|
3929
|
+
credentialId: selection.credentialId,
|
|
3930
|
+
accountId: selection.credential.accountId,
|
|
3931
|
+
email: selection.credential.email,
|
|
3932
|
+
projectId: selection.credential.projectId,
|
|
3933
|
+
enterpriseUrl: selection.credential.enterpriseUrl,
|
|
3934
|
+
error: error instanceof Error ? error.message : String(error),
|
|
3935
|
+
};
|
|
3936
|
+
}
|
|
3937
|
+
}
|
|
3938
|
+
|
|
3939
|
+
/**
|
|
3940
|
+
* Read-only list of stored OAuth accounts for `provider` in stable storage
|
|
3941
|
+
* order, WITHOUT refreshing any token. The array position (0-based) is the
|
|
3942
|
+
* selector accepted by {@link AuthStorage.getOAuthAccessAt}; a "pick the Nth
|
|
3943
|
+
* account" UI should render `position + 1`.
|
|
3944
|
+
*/
|
|
3945
|
+
listOAuthAccounts(provider: string): OAuthAccountSummary[] {
|
|
3946
|
+
if (this.#runtimeOverrides.has(provider) || this.#configOverrides.has(provider)) {
|
|
3947
|
+
return [];
|
|
3948
|
+
}
|
|
3949
|
+
return this.#getStoredOAuthSelections(provider).map((selection, position) => ({
|
|
3950
|
+
position,
|
|
3951
|
+
credentialId: selection.credentialId,
|
|
3952
|
+
accountId: selection.credential.accountId,
|
|
3953
|
+
email: selection.credential.email,
|
|
3954
|
+
projectId: selection.credential.projectId,
|
|
3955
|
+
enterpriseUrl: selection.credential.enterpriseUrl,
|
|
3956
|
+
}));
|
|
3957
|
+
}
|
|
3958
|
+
|
|
3787
3959
|
/**
|
|
3788
3960
|
* Resolve every stored OAuth credential for `provider` independently.
|
|
3789
3961
|
*
|
|
@@ -3797,62 +3969,38 @@ export class AuthStorage {
|
|
|
3797
3969
|
return [];
|
|
3798
3970
|
}
|
|
3799
3971
|
const providerKey = this.#getProviderTypeKey(provider, "oauth");
|
|
3800
|
-
const selections = this.#getStoredCredentials(provider)
|
|
3801
|
-
.map((entry, index) => ({ credentialId: entry.id, credential: entry.credential, index }))
|
|
3802
|
-
.filter(
|
|
3803
|
-
(entry): entry is { credentialId: number; credential: OAuthCredential; index: number } =>
|
|
3804
|
-
entry.credential.type === "oauth",
|
|
3805
|
-
);
|
|
3806
3972
|
return Promise.all(
|
|
3807
|
-
|
|
3808
|
-
|
|
3809
|
-
|
|
3810
|
-
provider,
|
|
3811
|
-
{ credential: selection.credential, index: selection.index },
|
|
3812
|
-
providerKey,
|
|
3813
|
-
undefined,
|
|
3814
|
-
options,
|
|
3815
|
-
{
|
|
3816
|
-
checkUsage: false,
|
|
3817
|
-
allowBlocked: true,
|
|
3818
|
-
},
|
|
3819
|
-
);
|
|
3820
|
-
if (!resolved) {
|
|
3821
|
-
return {
|
|
3822
|
-
ok: false,
|
|
3823
|
-
credentialId: selection.credentialId,
|
|
3824
|
-
accountId: selection.credential.accountId,
|
|
3825
|
-
email: selection.credential.email,
|
|
3826
|
-
projectId: selection.credential.projectId,
|
|
3827
|
-
enterpriseUrl: selection.credential.enterpriseUrl,
|
|
3828
|
-
error: "OAuth access unavailable",
|
|
3829
|
-
};
|
|
3830
|
-
}
|
|
3831
|
-
const { credential } = resolved;
|
|
3832
|
-
return {
|
|
3833
|
-
ok: true,
|
|
3834
|
-
credentialId: selection.credentialId,
|
|
3835
|
-
accessToken: credential.access,
|
|
3836
|
-
accountId: credential.accountId,
|
|
3837
|
-
email: credential.email,
|
|
3838
|
-
projectId: credential.projectId,
|
|
3839
|
-
enterpriseUrl: credential.enterpriseUrl,
|
|
3840
|
-
};
|
|
3841
|
-
} catch (error) {
|
|
3842
|
-
return {
|
|
3843
|
-
ok: false,
|
|
3844
|
-
credentialId: selection.credentialId,
|
|
3845
|
-
accountId: selection.credential.accountId,
|
|
3846
|
-
email: selection.credential.email,
|
|
3847
|
-
projectId: selection.credential.projectId,
|
|
3848
|
-
enterpriseUrl: selection.credential.enterpriseUrl,
|
|
3849
|
-
error: error instanceof Error ? error.message : String(error),
|
|
3850
|
-
};
|
|
3851
|
-
}
|
|
3852
|
-
}),
|
|
3973
|
+
this.#getStoredOAuthSelections(provider).map(selection =>
|
|
3974
|
+
this.#resolveStoredOAuthAccess(provider, selection, providerKey, options),
|
|
3975
|
+
),
|
|
3853
3976
|
);
|
|
3854
3977
|
}
|
|
3855
3978
|
|
|
3979
|
+
/**
|
|
3980
|
+
* Resolve a single stored OAuth credential by its account position (0-based,
|
|
3981
|
+
* matching {@link AuthStorage.listOAuthAccounts}). Refreshes ONLY that
|
|
3982
|
+
* credential ({@link #resolveStoredOAuthAccess} runs with `allowFallback:
|
|
3983
|
+
* false`), so — unlike {@link AuthStorage.getOAuthAccesses} — a definitive
|
|
3984
|
+
* failure of the targeted account surfaces as a failed resolution rather than
|
|
3985
|
+
* silently rotating or rate-tripping a sibling.
|
|
3986
|
+
*
|
|
3987
|
+
* Returns `undefined` when `position` is out of range or runtime/config
|
|
3988
|
+
* overrides have replaced OAuth with an explicit API key.
|
|
3989
|
+
*/
|
|
3990
|
+
async getOAuthAccessAt(
|
|
3991
|
+
provider: string,
|
|
3992
|
+
position: number,
|
|
3993
|
+
options?: AuthApiKeyOptions,
|
|
3994
|
+
): Promise<OAuthAccessResolution | undefined> {
|
|
3995
|
+
if (this.#runtimeOverrides.has(provider) || this.#configOverrides.has(provider)) {
|
|
3996
|
+
return undefined;
|
|
3997
|
+
}
|
|
3998
|
+
const selection = this.#getStoredOAuthSelections(provider)[position];
|
|
3999
|
+
if (!selection) return undefined;
|
|
4000
|
+
const providerKey = this.#getProviderTypeKey(provider, "oauth");
|
|
4001
|
+
return this.#resolveStoredOAuthAccess(provider, selection, providerKey, options);
|
|
4002
|
+
}
|
|
4003
|
+
|
|
3856
4004
|
/**
|
|
3857
4005
|
* List saved rate-limit resets for every stored OAuth account of `provider`
|
|
3858
4006
|
* (Codex), fetched LIVE from the dedicated `rate-limit-reset-credits` route.
|
|
@@ -4229,22 +4377,55 @@ export class AuthStorage {
|
|
|
4229
4377
|
if (target.credential.type !== "oauth") {
|
|
4230
4378
|
throw new Error(`Credential ${id} is not OAuth (provider=${provider}, type=${target.credential.type})`);
|
|
4231
4379
|
}
|
|
4380
|
+
// The exact credential we are about to refresh — captured before the
|
|
4381
|
+
// await so a definitive failure can CAS-disable the row against the
|
|
4382
|
+
// value we actually attempted (NOT the expires:0 clone below).
|
|
4383
|
+
const attempted = target.credential;
|
|
4232
4384
|
// Pass a clone with expires=0 so the cached not-yet-expired short-circuit
|
|
4233
4385
|
// in #refreshOAuthCredential doesn't suppress the requested refresh.
|
|
4234
|
-
const stale: OAuthCredential = { ...
|
|
4235
|
-
|
|
4386
|
+
const stale: OAuthCredential = { ...attempted, expires: 0 };
|
|
4387
|
+
let refreshed: OAuthCredentials;
|
|
4388
|
+
try {
|
|
4389
|
+
refreshed = await this.#refreshOAuthCredential(provider as Provider, stale, id, signal);
|
|
4390
|
+
} catch (error) {
|
|
4391
|
+
// A definitively-dead grant tears the row down here, where the
|
|
4392
|
+
// attempted credential is known. CAS on the persisted credential so a
|
|
4393
|
+
// peer/login rotation in flight leaves the freshly-rotated row intact.
|
|
4394
|
+
if (isDefinitiveOAuthFailure(String(error))) {
|
|
4395
|
+
// CAS-loss (false) means a peer/login rotated the row mid-refresh, so
|
|
4396
|
+
// our #data copy is stale — reload so the next caller serves the
|
|
4397
|
+
// freshly-rotated credential rather than the dead token we attempted.
|
|
4398
|
+
if (
|
|
4399
|
+
!this.#disableCredentialByIdIfMatches(
|
|
4400
|
+
provider,
|
|
4401
|
+
id,
|
|
4402
|
+
attempted,
|
|
4403
|
+
`oauth refresh failed: ${String(error)}`,
|
|
4404
|
+
)
|
|
4405
|
+
) {
|
|
4406
|
+
await this.reload();
|
|
4407
|
+
}
|
|
4408
|
+
}
|
|
4409
|
+
throw error;
|
|
4410
|
+
}
|
|
4236
4411
|
const updated: OAuthCredential = {
|
|
4237
4412
|
type: "oauth",
|
|
4238
4413
|
access: refreshed.access,
|
|
4239
4414
|
refresh: refreshed.refresh,
|
|
4240
4415
|
expires: refreshed.expires,
|
|
4241
|
-
accountId: refreshed.accountId ??
|
|
4242
|
-
email: refreshed.email ??
|
|
4243
|
-
projectId: refreshed.projectId ??
|
|
4244
|
-
enterpriseUrl: refreshed.enterpriseUrl ??
|
|
4245
|
-
apiEndpoint: refreshed.apiEndpoint ??
|
|
4416
|
+
accountId: refreshed.accountId ?? attempted.accountId,
|
|
4417
|
+
email: refreshed.email ?? attempted.email,
|
|
4418
|
+
projectId: refreshed.projectId ?? attempted.projectId,
|
|
4419
|
+
enterpriseUrl: refreshed.enterpriseUrl ?? attempted.enterpriseUrl,
|
|
4420
|
+
apiEndpoint: refreshed.apiEndpoint ?? attempted.apiEndpoint,
|
|
4246
4421
|
};
|
|
4247
|
-
|
|
4422
|
+
// Persist by id: the array may have been reordered/shrunk while the
|
|
4423
|
+
// refresh was in flight, so the pre-await positional index is unsafe. A
|
|
4424
|
+
// -1 means the row was disabled/removed mid-refresh — surface that as a
|
|
4425
|
+
// miss rather than implying a live row the snapshot won't contain.
|
|
4426
|
+
if (this.#replaceCredentialById(provider, id, updated) === -1) {
|
|
4427
|
+
throw new Error(`No credential with id=${id}`);
|
|
4428
|
+
}
|
|
4248
4429
|
return {
|
|
4249
4430
|
id,
|
|
4250
4431
|
provider,
|
|
@@ -87,6 +87,76 @@ function inferRegionFromBedrockArn(modelId: string): string | undefined {
|
|
|
87
87
|
return region || undefined;
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Default AWS region for each Bedrock cross-region inference-profile geo prefix.
|
|
92
|
+
* A geo-prefixed profile (e.g. `eu.anthropic.claude-…`) is only servable from
|
|
93
|
+
* regions in its own geo, so routing one to `us-east-1` yields HTTP 400 "The
|
|
94
|
+
* provided model identifier is invalid." `global.` profiles are anchored in the
|
|
95
|
+
* us regions and intentionally absent here (they resolve fine via `us-east-1`).
|
|
96
|
+
*/
|
|
97
|
+
const INFERENCE_PROFILE_GEO_DEFAULT_REGION: Record<string, string> = {
|
|
98
|
+
us: "us-east-1",
|
|
99
|
+
"us-gov": "us-gov-west-1",
|
|
100
|
+
eu: "eu-west-1",
|
|
101
|
+
apac: "ap-southeast-1",
|
|
102
|
+
au: "ap-southeast-2",
|
|
103
|
+
jp: "ap-northeast-1",
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/** Geo prefix of a cross-region inference-profile id, e.g. `eu.anthropic.…` → `eu`. */
|
|
107
|
+
function inferenceProfileGeo(modelId: string): string | undefined {
|
|
108
|
+
const dot = modelId.indexOf(".");
|
|
109
|
+
if (dot <= 0) return undefined;
|
|
110
|
+
const prefix = modelId.slice(0, dot);
|
|
111
|
+
return prefix in INFERENCE_PROFILE_GEO_DEFAULT_REGION ? prefix : undefined;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Whether a concrete AWS region can serve a given inference-profile geo. The
|
|
116
|
+
* `ap-` regions overlap across `apac`/`au`/`jp` profiles, so the Australia and
|
|
117
|
+
* Japan geos pin their specific source regions rather than matching all `ap-*`.
|
|
118
|
+
*/
|
|
119
|
+
function regionServesGeo(region: string, geo: string): boolean {
|
|
120
|
+
switch (geo) {
|
|
121
|
+
case "us-gov":
|
|
122
|
+
return region.startsWith("us-gov-");
|
|
123
|
+
case "us":
|
|
124
|
+
return region.startsWith("us-") && !region.startsWith("us-gov-");
|
|
125
|
+
case "eu":
|
|
126
|
+
return region.startsWith("eu-");
|
|
127
|
+
case "apac":
|
|
128
|
+
return region.startsWith("ap-");
|
|
129
|
+
case "au":
|
|
130
|
+
return region === "ap-southeast-2" || region === "ap-southeast-4";
|
|
131
|
+
case "jp":
|
|
132
|
+
return region === "ap-northeast-1" || region === "ap-northeast-3";
|
|
133
|
+
default:
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Resolve the Bedrock runtime region for a request. An explicit per-request
|
|
140
|
+
* region and an ARN-embedded region win outright. Otherwise, for a geo-prefixed
|
|
141
|
+
* cross-region inference profile (`us.`/`eu.`/`apac.`/`au.`/`jp.`/`us-gov.`), an
|
|
142
|
+
* ambient region (`AWS_REGION` / `AWS_DEFAULT_REGION`) is honored only when it
|
|
143
|
+
* can serve the profile's geo; a mismatched or absent ambient region is
|
|
144
|
+
* corrected to the geo default so an `eu.`/`apac.` profile never POSTs to a `us`
|
|
145
|
+
* endpoint (and vice versa). `global.` profiles have no geo entry, so the
|
|
146
|
+
* ambient region (or `us-east-1`) is used unchanged.
|
|
147
|
+
*/
|
|
148
|
+
function resolveBedrockRegion(modelId: string, options: BedrockOptions): string {
|
|
149
|
+
const explicit = options.region || inferRegionFromBedrockArn(modelId);
|
|
150
|
+
if (explicit) return explicit;
|
|
151
|
+
const ambient = $env.AWS_REGION || $env.AWS_DEFAULT_REGION;
|
|
152
|
+
const geo = inferenceProfileGeo(modelId);
|
|
153
|
+
if (geo) {
|
|
154
|
+
if (ambient && regionServesGeo(ambient, geo)) return ambient;
|
|
155
|
+
return INFERENCE_PROFILE_GEO_DEFAULT_REGION[geo];
|
|
156
|
+
}
|
|
157
|
+
return ambient || "us-east-1";
|
|
158
|
+
}
|
|
159
|
+
|
|
90
160
|
type Block = (TextContent | ThinkingContent | ToolCall) & {
|
|
91
161
|
index?: number;
|
|
92
162
|
partialJson?: string;
|
|
@@ -235,12 +305,7 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream"> = (
|
|
|
235
305
|
|
|
236
306
|
const blocks = output.content as Block[];
|
|
237
307
|
let rawRequestDump: RawHttpRequestDump | undefined;
|
|
238
|
-
const region =
|
|
239
|
-
options.region ||
|
|
240
|
-
inferRegionFromBedrockArn(model.id) ||
|
|
241
|
-
$env.AWS_REGION ||
|
|
242
|
-
$env.AWS_DEFAULT_REGION ||
|
|
243
|
-
"us-east-1";
|
|
308
|
+
const region = resolveBedrockRegion(model.id, options);
|
|
244
309
|
|
|
245
310
|
try {
|
|
246
311
|
const cacheRetention = resolveCacheRetention(options.cacheRetention);
|
package/src/providers/ollama.ts
CHANGED
|
@@ -192,14 +192,18 @@ function toPlainContent(
|
|
|
192
192
|
};
|
|
193
193
|
}
|
|
194
194
|
|
|
195
|
-
function convertMessage(
|
|
195
|
+
function convertMessage(
|
|
196
|
+
message: Message,
|
|
197
|
+
supportsImages: boolean,
|
|
198
|
+
developerRole: "system" | "user" = "user",
|
|
199
|
+
): OllamaMessage {
|
|
196
200
|
if (message.role === "user") {
|
|
197
201
|
const converted = toPlainContent(message.content, supportsImages);
|
|
198
202
|
return { role: "user", ...converted };
|
|
199
203
|
}
|
|
200
204
|
if (message.role === "developer") {
|
|
201
205
|
const converted = toPlainContent(message.content, supportsImages);
|
|
202
|
-
return { role:
|
|
206
|
+
return { role: developerRole, ...converted };
|
|
203
207
|
}
|
|
204
208
|
if (message.role === "toolResult") {
|
|
205
209
|
const converted = toPlainContent(message.content, supportsImages);
|
|
@@ -240,23 +244,27 @@ function convertMessage(message: Message, supportsImages: boolean): OllamaMessag
|
|
|
240
244
|
}
|
|
241
245
|
|
|
242
246
|
function convertMessages(model: Model<"ollama-chat">, context: Context): OllamaMessage[] {
|
|
243
|
-
const
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
role: "developer",
|
|
251
|
-
content: systemPrompt,
|
|
252
|
-
timestamp: Date.now(),
|
|
253
|
-
});
|
|
254
|
-
}
|
|
255
|
-
messages.push(...context.messages);
|
|
247
|
+
const systemPrompts = normalizeSystemPrompts(context.systemPrompt);
|
|
248
|
+
const systemMessages: Message[] = systemPrompts.map(systemPrompt => ({
|
|
249
|
+
role: "developer",
|
|
250
|
+
content: systemPrompt,
|
|
251
|
+
timestamp: Date.now(),
|
|
252
|
+
}));
|
|
253
|
+
const messages: Message[] = [...systemMessages, ...context.messages];
|
|
256
254
|
const isCloud = model.provider === "ollama-cloud";
|
|
257
255
|
const supportsImages = model.input.includes("image");
|
|
258
|
-
return transformMessages(messages, model).map(msg => {
|
|
259
|
-
|
|
256
|
+
return transformMessages(messages, model).map((msg, index) => {
|
|
257
|
+
// Real `systemPrompt` entries (always emitted first) stay on Ollama's
|
|
258
|
+
// `system` role. After the static prefix, a developer turn keeps `system`
|
|
259
|
+
// when it's an agent-owned control instruction (empty/unexpected-stop
|
|
260
|
+
// retries, checkpoint rewind warning, todo reminders — all carry
|
|
261
|
+
// `attribution: "agent"`), but a user-attributed developer turn (auto-learn
|
|
262
|
+
// capture nudge, advisor cards, file-mention companions) drops to `user`.
|
|
263
|
+
// That keeps the in-conversation byte prefix stable for prefix caches
|
|
264
|
+
// (llama.cpp, #3456) without demoting mandatory agent reminders.
|
|
265
|
+
const developerRole =
|
|
266
|
+
msg.role === "developer" && (index < systemPrompts.length || msg.attribution !== "user") ? "system" : "user";
|
|
267
|
+
const converted = convertMessage(msg, supportsImages, developerRole);
|
|
260
268
|
// Ollama cloud rejects requests when assistant history messages contain the `thinking`
|
|
261
269
|
// field — it's valid in model responses but not accepted as a history input. Strip it
|
|
262
270
|
// to prevent HTTP 400 errors. Local Ollama instances are unaffected.
|
|
@@ -59,6 +59,26 @@ export interface RequestBody {
|
|
|
59
59
|
[key: string]: unknown;
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
function containsInputImage(value: unknown): boolean {
|
|
63
|
+
if (!value || typeof value !== "object") return false;
|
|
64
|
+
if ((value as { type?: unknown }).type === "input_image") return true;
|
|
65
|
+
if (Array.isArray(value)) {
|
|
66
|
+
for (const item of value) {
|
|
67
|
+
if (containsInputImage(item)) return true;
|
|
68
|
+
}
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
for (const item of Object.values(value)) {
|
|
72
|
+
if (containsInputImage(item)) return true;
|
|
73
|
+
}
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Returns whether a Codex request can use the text-only Responses Lite transport. */
|
|
78
|
+
export function shouldUseCodexResponsesLite(body: RequestBody, requested: boolean | undefined): boolean {
|
|
79
|
+
return requested === true && !containsInputImage(body.input);
|
|
80
|
+
}
|
|
81
|
+
|
|
62
82
|
function getReasoningConfig(model: Model<Api>, options: CodexRequestOptions): ReasoningConfig {
|
|
63
83
|
const config: ReasoningConfig = {
|
|
64
84
|
effort:
|
|
@@ -211,7 +231,8 @@ export async function transformRequestBody(
|
|
|
211
231
|
body.input = [...developerMessages, ...body.input];
|
|
212
232
|
}
|
|
213
233
|
|
|
214
|
-
|
|
234
|
+
const responsesLite = shouldUseCodexResponsesLite(body, options.responsesLite);
|
|
235
|
+
if (responsesLite) {
|
|
215
236
|
if (Array.isArray(body.input)) {
|
|
216
237
|
stripImageDetails(body.input);
|
|
217
238
|
}
|
|
@@ -231,7 +252,7 @@ export async function transformRequestBody(
|
|
|
231
252
|
// Responses Lite keeps reasoning replay server-side; codex-rs requests
|
|
232
253
|
// `all_turns` there and otherwise omits context so the server default
|
|
233
254
|
// (currently `current_turn`) applies.
|
|
234
|
-
const reasoningContext = options.reasoningContext ?? (
|
|
255
|
+
const reasoningContext = options.reasoningContext ?? (responsesLite ? "all_turns" : undefined);
|
|
235
256
|
if (reasoningContext !== undefined) {
|
|
236
257
|
body.reasoning.context = reasoningContext;
|
|
237
258
|
}
|
|
@@ -61,6 +61,7 @@ import {
|
|
|
61
61
|
type CodexRequestOptions,
|
|
62
62
|
type InputItem,
|
|
63
63
|
type RequestBody,
|
|
64
|
+
shouldUseCodexResponsesLite,
|
|
64
65
|
transformRequestBody,
|
|
65
66
|
} from "./openai-codex/request-transformer";
|
|
66
67
|
import { CodexApiError } from "./openai-codex/response-handler";
|
|
@@ -697,7 +698,7 @@ async function buildCodexRequestContext(
|
|
|
697
698
|
};
|
|
698
699
|
|
|
699
700
|
const providerSessionState = getCodexProviderSessionState(options?.providerSessionState);
|
|
700
|
-
const responsesLite = options?.responsesLite
|
|
701
|
+
const responsesLite = shouldUseCodexResponsesLite(transformedBody, options?.responsesLite);
|
|
701
702
|
const sessionKey = getCodexWebSocketSessionKey(transportSessionId, model, accountId, baseUrl, responsesLite);
|
|
702
703
|
const publicSessionKey = transportSessionId ? `${baseUrl}:${model.id}:${transportSessionId}` : undefined;
|
|
703
704
|
if (sessionKey && publicSessionKey) {
|
|
@@ -1629,6 +1629,7 @@ export function convertMessages(
|
|
|
1629
1629
|
id => normalizeToolCallId(id),
|
|
1630
1630
|
maxNormalizedToolCallIdLength,
|
|
1631
1631
|
duplicateToolCallIdSuffixPrefix,
|
|
1632
|
+
compat,
|
|
1632
1633
|
);
|
|
1633
1634
|
|
|
1634
1635
|
const remappedToolCallIds = new Map<string, string[]>();
|
|
@@ -1793,6 +1794,17 @@ export function convertMessages(
|
|
|
1793
1794
|
if (wireField) {
|
|
1794
1795
|
assistantMsg[wireField] = nonEmptyThinkingBlocks.map(b => b.thinking).join("\n");
|
|
1795
1796
|
}
|
|
1797
|
+
} else if (compat.thinkingFormat === "zai" && model.reasoning) {
|
|
1798
|
+
// Z.AI / Zhipu / Moonshot Kimi (native) / Xiaomi MiMo accept
|
|
1799
|
+
// `reasoning_content` as a continuation hint even when they don't
|
|
1800
|
+
// strictly require it. Surfacing the preserved thinking text here
|
|
1801
|
+
// keeps cross-API replays (Z.AI Anthropic → Z.AI OpenAI, etc.)
|
|
1802
|
+
// shipping reasoning as structured `reasoning_content` rather than
|
|
1803
|
+
// folded into conversation text (#3434). Signature is irrelevant on
|
|
1804
|
+
// this path: `transform-messages` strips the source wire-format
|
|
1805
|
+
// signature on cross-API replays before the block reaches us.
|
|
1806
|
+
const reasoningField = compat.reasoningContentField ?? "reasoning_content";
|
|
1807
|
+
assistantMsg[reasoningField] = nonEmptyThinkingBlocks.map(b => b.thinking).join("\n");
|
|
1796
1808
|
}
|
|
1797
1809
|
}
|
|
1798
1810
|
|
|
@@ -124,6 +124,88 @@ function deduplicateToolCallIds(
|
|
|
124
124
|
});
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
/**
|
|
128
|
+
* Drop assistant `toolCall` blocks whose `name` is empty or whitespace-only,
|
|
129
|
+
* the `toolResult` messages they point at, and any assistant turn that has no
|
|
130
|
+
* replayable content left.
|
|
131
|
+
*
|
|
132
|
+
* Models occasionally emit `{ "name": "", "arguments": "{}" }` (observed:
|
|
133
|
+
* GLM-5.2 + thinking on long turns, #3458). The agent loop rejects the call
|
|
134
|
+
* at execution time with `Tool not found`, but the malformed block and its
|
|
135
|
+
* error tool-result stay in `currentContext.messages`, so every subsequent
|
|
136
|
+
* request replays them. Every provider validates the function name —
|
|
137
|
+
* Anthropic 400s on `tool_use.name` (alongside an orphan `tool_result`),
|
|
138
|
+
* OpenAI Chat Completions 400s on `tool_calls[i].function.name` — wedging the
|
|
139
|
+
* session in a 400 loop until manual `/clear`.
|
|
140
|
+
*
|
|
141
|
+
* Run before any other transform so the rest of the pipeline never sees a
|
|
142
|
+
* malformed call. Idempotent: a re-run on an already-sanitized list returns
|
|
143
|
+
* the input untouched. Provider-agnostic — any wire model could surface this.
|
|
144
|
+
*/
|
|
145
|
+
function isMalformedToolCallName(name: string | undefined): boolean {
|
|
146
|
+
return !name || name.trim().length === 0;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function sanitizeMalformedToolCalls(messages: Message[]): Message[] {
|
|
150
|
+
// Fast path: skip the rewrite entirely when nothing is malformed.
|
|
151
|
+
let hasMalformed = false;
|
|
152
|
+
outer: for (const msg of messages) {
|
|
153
|
+
if (msg.role !== "assistant") continue;
|
|
154
|
+
for (const block of msg.content) {
|
|
155
|
+
if (block.type === "toolCall" && isMalformedToolCallName(block.name)) {
|
|
156
|
+
hasMalformed = true;
|
|
157
|
+
break outer;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (!hasMalformed) return messages;
|
|
162
|
+
|
|
163
|
+
// Positional FIFO pairing within one assistant→tool-result window: a tool-call
|
|
164
|
+
// id can repeat across history when an OpenAI-Responses composite id
|
|
165
|
+
// (`callId|itemId`) collapses on the wire to the same `callId` (see
|
|
166
|
+
// `deduplicateToolCallIds` + `transform-messages-dedup`). A set-based "drop
|
|
167
|
+
// every result for this id" loses the real output for the surviving valid
|
|
168
|
+
// occurrence whenever one duplicate is malformed. Track each `toolCall`
|
|
169
|
+
// occurrence's malformed-ness on a per-id queue and pop on matching
|
|
170
|
+
// `toolResult`, but clear the queues at every non-result boundary so a
|
|
171
|
+
// malformed call whose rejection result never arrived cannot consume a later
|
|
172
|
+
// valid call's real result when the id is reused.
|
|
173
|
+
const dropQueues = new Map<string, boolean[]>();
|
|
174
|
+
const result: Message[] = [];
|
|
175
|
+
for (const msg of messages) {
|
|
176
|
+
if (msg.role === "assistant") {
|
|
177
|
+
dropQueues.clear();
|
|
178
|
+
const filtered: AssistantMessage["content"] = [];
|
|
179
|
+
for (const block of msg.content) {
|
|
180
|
+
if (block.type === "toolCall") {
|
|
181
|
+
const malformed = isMalformedToolCallName(block.name);
|
|
182
|
+
const queue = dropQueues.get(block.id);
|
|
183
|
+
if (queue) queue.push(malformed);
|
|
184
|
+
else dropQueues.set(block.id, [malformed]);
|
|
185
|
+
if (malformed) continue;
|
|
186
|
+
}
|
|
187
|
+
filtered.push(block);
|
|
188
|
+
}
|
|
189
|
+
if (filtered.length === 0) continue;
|
|
190
|
+
result.push(filtered.length === msg.content.length ? msg : { ...msg, content: filtered });
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
if (msg.role === "toolResult") {
|
|
194
|
+
const queue = dropQueues.get(msg.toolCallId);
|
|
195
|
+
if (queue && queue.length > 0) {
|
|
196
|
+
const drop = queue.shift() === true;
|
|
197
|
+
if (queue.length === 0) dropQueues.delete(msg.toolCallId);
|
|
198
|
+
if (drop) continue;
|
|
199
|
+
}
|
|
200
|
+
result.push(msg);
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
dropQueues.clear();
|
|
204
|
+
result.push(msg);
|
|
205
|
+
}
|
|
206
|
+
return result;
|
|
207
|
+
}
|
|
208
|
+
|
|
127
209
|
function shouldDropTruncatedThinkingOnlyAssistant(msg: AssistantMessage): boolean {
|
|
128
210
|
const isTruncatedStop = msg.stopReason === "length" || msg.stopReason === "error" || msg.stopReason === "aborted";
|
|
129
211
|
return isTruncatedStop && !msg.content.some(block => block.type === "toolCall" || block.type === "text");
|
|
@@ -143,6 +225,44 @@ function isAnthropicMessagesModel(model: Model): model is Model<"anthropic-messa
|
|
|
143
225
|
return model.api === "anthropic-messages";
|
|
144
226
|
}
|
|
145
227
|
|
|
228
|
+
/**
|
|
229
|
+
* Cross-API `openai-completions` targets that can replay a prior turn's
|
|
230
|
+
* reasoning as a native, signature-stripped `thinking` block on the wire.
|
|
231
|
+
* Anthropic's same-API path (`replayUnsignedThinking`) covers
|
|
232
|
+
* `anthropic-messages` targets directly; this is the analogue for the
|
|
233
|
+
* `openai-completions` branch of the cross-API path (#3433/#3434). 3p ↔ 3p
|
|
234
|
+
* replays between an Anthropic-compatible source (Z.AI Anthropic, Kimi
|
|
235
|
+
* Anthropic, …) and an OpenAI-compat reasoning target on the same vendor must
|
|
236
|
+
* keep reasoning as structured `reasoning_content` instead of degrading it to
|
|
237
|
+
* conversation text.
|
|
238
|
+
*
|
|
239
|
+
* `compat` MUST be the request-time RESOLVED compat that `convertMessages`
|
|
240
|
+
* threads into `transformMessages`, not `model.compat`. OpenCode-hosted
|
|
241
|
+
* reasoning models (`opencode-go`/`opencode-zen`) keep
|
|
242
|
+
* `requiresReasoningContentForToolCalls` off on the base compat to dodge the
|
|
243
|
+
* thinking-off `Extra inputs are not permitted` 400 (#1071) and reactivate it
|
|
244
|
+
* on `compat.whenThinking` for thinking-engaged requests to dodge the
|
|
245
|
+
* `thinking is enabled but reasoning_content is missing` 400 (#1484).
|
|
246
|
+
* `resolveOpenAICompatPolicy` already swaps in `whenThinking` for thinking-on
|
|
247
|
+
* requests, so basing this decision on the resolved compat keeps the predicate
|
|
248
|
+
* and the encoder in lockstep; reading `model.compat` would re-open #1484 for
|
|
249
|
+
* every cross-API switch into an OpenCode reasoning model.
|
|
250
|
+
*
|
|
251
|
+
* The downstream encoder MUST then surface the preserved block on the wire via
|
|
252
|
+
* `reasoningContentField` — see `openai-completions.ts` for the matching
|
|
253
|
+
* branch.
|
|
254
|
+
*/
|
|
255
|
+
function openAICompletionsReplaysUnsignedThinking(model: Model, compat: Model["compat"]): boolean {
|
|
256
|
+
if (model.api !== "openai-completions" || !model.reasoning) return false;
|
|
257
|
+
if (compat === undefined || !("requiresReasoningContentForToolCalls" in compat)) return false;
|
|
258
|
+
if (compat.requiresThinkingAsText) return false;
|
|
259
|
+
// Hosts that REQUIRE `reasoning_content` on tool-call turns (DeepSeek
|
|
260
|
+
// reasoning, Kimi, OpenRouter reasoning, OpenCode thinking-on) already
|
|
261
|
+
// accept the replay; Z.AI-format hosts (Z.AI, Zhipu, Moonshot Kimi native,
|
|
262
|
+
// Xiaomi MiMo) advertise `reasoning_content` as a continuation hint.
|
|
263
|
+
return compat.requiresReasoningContentForToolCalls || compat.thinkingFormat === "zai";
|
|
264
|
+
}
|
|
265
|
+
|
|
146
266
|
const ANTHROPIC_TOOL_CALL_ID_PATTERN = /^[a-zA-Z0-9_-]{1,64}$/;
|
|
147
267
|
|
|
148
268
|
function isValidAnthropicToolCallId(id: string): boolean {
|
|
@@ -181,7 +301,13 @@ export function transformMessages<TApi extends Api>(
|
|
|
181
301
|
normalizeToolCallId?: (id: string, model: Model<TApi>, source: AssistantMessage) => string,
|
|
182
302
|
maxNormalizedToolCallIdLength = MAX_TOOL_CALL_ID_LENGTH,
|
|
183
303
|
duplicateToolCallIdSuffixPrefix = "_dup",
|
|
304
|
+
targetCompat: Model<TApi>["compat"] = model.compat,
|
|
184
305
|
): Message[] {
|
|
306
|
+
// Drop assistant `toolCall` blocks with empty/whitespace `name` (and their
|
|
307
|
+
// matched `toolResult` messages) before anything else looks at the history.
|
|
308
|
+
// Replays of these would 400 every provider — see `sanitizeMalformedToolCalls`.
|
|
309
|
+
messages = sanitizeMalformedToolCalls(messages);
|
|
310
|
+
|
|
185
311
|
// Build a map of original tool call IDs to normalized IDs
|
|
186
312
|
const toolCallIdMap = new Map<string, string>();
|
|
187
313
|
|
|
@@ -316,13 +442,34 @@ export function transformMessages<TApi extends Api>(
|
|
|
316
442
|
}
|
|
317
443
|
return sanitized;
|
|
318
444
|
}
|
|
319
|
-
// Cross-API target:
|
|
320
|
-
//
|
|
321
|
-
//
|
|
445
|
+
// Cross-API target: same-model replay keeps signatures untouched
|
|
446
|
+
// (the encoder needs them for native replay; an OpenAI encrypted
|
|
447
|
+
// reasoning blob has empty text but a load-bearing signature).
|
|
322
448
|
if (isSameModel && sanitized.thinkingSignature) return sanitized;
|
|
323
|
-
//
|
|
449
|
+
// Nothing left for the next turn to replay: drop empty/no-anchor
|
|
450
|
+
// thinking blocks before the cross-model paths.
|
|
324
451
|
if (!sanitized.thinking || sanitized.thinking.trim() === "") return [];
|
|
325
452
|
if (isSameModel) return sanitized;
|
|
453
|
+
// Cross-model + cross-API: preserve as a native, signature-stripped
|
|
454
|
+
// `thinking` block whenever the target encoder can re-emit it on the
|
|
455
|
+
// wire (today: `openai-completions` reasoning targets that accept
|
|
456
|
+
// `reasoning_content` as a continuation hint — Z.AI, Zhipu, DeepSeek
|
|
457
|
+
// reasoning, Kimi native, MiMo, OpenRouter reasoning, …). The source
|
|
458
|
+
// signature is always dropped because it is bound to the source
|
|
459
|
+
// wire-format (Anthropic crypto sig / OpenAI Responses encrypted
|
|
460
|
+
// blob) and would be rejected by the target. Without this branch
|
|
461
|
+
// every cross-API 3p ↔ 3p switch (Z.AI Anthropic → Z.AI OpenAI,
|
|
462
|
+
// Kimi Anthropic → Kimi OpenAI, etc.) demoted prior reasoning to
|
|
463
|
+
// conversation text and lost it as structured reasoning context
|
|
464
|
+
// (#3433/#3434).
|
|
465
|
+
if (openAICompletionsReplaysUnsignedThinking(model, targetCompat)) {
|
|
466
|
+
return sanitized.thinkingSignature ? { ...sanitized, thinkingSignature: undefined } : sanitized;
|
|
467
|
+
}
|
|
468
|
+
// Other cross-API targets (openai-responses encrypted blobs, google
|
|
469
|
+
// signed thought parts, anthropic-target from a non-Anthropic source,
|
|
470
|
+
// or any reasoning-disabled target) can't usefully replay an unsigned
|
|
471
|
+
// thinking block. Demote to text so the reasoning survives at least
|
|
472
|
+
// as visible conversation context.
|
|
326
473
|
return {
|
|
327
474
|
type: "text" as const,
|
|
328
475
|
text: sanitized.thinking,
|