@oh-my-pi/pi-ai 18.2.5 → 18.2.7

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 (50) hide show
  1. package/CHANGELOG.md +20 -1
  2. package/THIRD-PARTY-NOTICES.txt +0 -37
  3. package/dist/types/auth-retry.d.ts +2 -0
  4. package/dist/types/auth-storage.d.ts +2 -2
  5. package/dist/types/error/classes.d.ts +4 -1
  6. package/dist/types/error/flags.d.ts +12 -4
  7. package/dist/types/index.d.ts +14 -12
  8. package/dist/types/judgment/types.d.ts +5 -2
  9. package/dist/types/judgment/typesafe.d.ts +16 -22
  10. package/dist/types/providers/anthropic-compaction.d.ts +10 -0
  11. package/dist/types/providers/anthropic-identity.d.ts +18 -0
  12. package/dist/types/providers/anthropic-state.d.ts +13 -0
  13. package/dist/types/providers/anthropic.d.ts +6 -59
  14. package/dist/types/providers/gitlab-duo.d.ts +0 -1
  15. package/dist/types/providers/kimi.d.ts +1 -5
  16. package/dist/types/providers/openai-codex-attestation.d.ts +6 -0
  17. package/dist/types/providers/openai-codex-compaction.d.ts +6 -0
  18. package/dist/types/providers/openai-codex-responses.d.ts +3 -27
  19. package/dist/types/providers/openai-codex-transport.d.ts +7 -0
  20. package/dist/types/providers/openai-shared.d.ts +0 -9
  21. package/dist/types/providers/register-builtins.d.ts +20 -22
  22. package/dist/types/providers/synthetic.d.ts +1 -5
  23. package/package.json +6 -6
  24. package/src/auth-broker/discover.ts +5 -3
  25. package/src/auth-retry.ts +3 -0
  26. package/src/auth-storage.ts +28 -22
  27. package/src/error/auth-classify.ts +10 -2
  28. package/src/error/classes.ts +23 -3
  29. package/src/error/flags.ts +51 -16
  30. package/src/index.ts +15 -12
  31. package/src/judgment/types.ts +6 -3
  32. package/src/judgment/typesafe.ts +53 -48
  33. package/src/provider-session-state.ts +1 -1
  34. package/src/providers/anthropic-compaction.ts +54 -0
  35. package/src/providers/anthropic-identity.ts +136 -0
  36. package/src/providers/anthropic-state.ts +55 -0
  37. package/src/providers/anthropic.ts +157 -326
  38. package/src/providers/bedrock-mantle.ts +1 -1
  39. package/src/providers/gitlab-duo.ts +0 -4
  40. package/src/providers/kimi.ts +1 -8
  41. package/src/providers/openai-codex-attestation.ts +19 -0
  42. package/src/providers/openai-codex-compaction.ts +18 -0
  43. package/src/providers/openai-codex-responses.ts +9 -68
  44. package/src/providers/openai-codex-transport.ts +18 -0
  45. package/src/providers/openai-shared.ts +1 -10
  46. package/src/providers/register-builtins.ts +113 -320
  47. package/src/providers/synthetic.ts +1 -8
  48. package/src/registry/cloudflare-ai-gateway.ts +1 -1
  49. package/src/stream.ts +7 -15
  50. package/src/utils/anthropic-auth.ts +3 -6
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-ai",
3
- "version": "18.2.5",
3
+ "version": "18.2.7",
4
4
  "description": "Unified LLM API with automatic model discovery and provider configuration",
5
5
  "keywords": [
6
6
  "ai",
@@ -128,11 +128,11 @@
128
128
  "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
129
129
  },
130
130
  "dependencies": {
131
- "@oh-my-pi/omptype": "18.2.5",
132
- "@oh-my-pi/pi-catalog": "18.2.5",
133
- "@oh-my-pi/pi-natives": "18.2.5",
134
- "@oh-my-pi/pi-utils": "18.2.5",
135
- "@oh-my-pi/pi-wire": "18.2.5"
131
+ "@oh-my-pi/omptype": "18.2.7",
132
+ "@oh-my-pi/pi-catalog": "18.2.7",
133
+ "@oh-my-pi/pi-natives": "18.2.7",
134
+ "@oh-my-pi/pi-utils": "18.2.7",
135
+ "@oh-my-pi/pi-wire": "18.2.7"
136
136
  },
137
137
  "devDependencies": {
138
138
  "@types/bun": "^1.3.14"
@@ -4,6 +4,7 @@
4
4
  * token file → local SQLite) in one place so build-time tooling sees the same
5
5
  * credentials as the TUI.
6
6
  */
7
+ import * as fs from "node:fs/promises";
7
8
  import * as path from "node:path";
8
9
  import {
9
10
  $envExact,
@@ -60,7 +61,7 @@ async function defaultResolveConfigValue(config: string): Promise<string | undef
60
61
 
61
62
  async function readTokenFile(): Promise<string | null> {
62
63
  try {
63
- const raw = await Bun.file(getAuthBrokerTokenFilePath()).text();
64
+ const raw = await fs.readFile(getAuthBrokerTokenFilePath(), "utf8");
64
65
  const trimmed = raw.trim();
65
66
  return trimmed.length > 0 ? trimmed : null;
66
67
  } catch (err) {
@@ -99,7 +100,7 @@ async function readConfigYaml(agentDir: string): Promise<ConfigSnapshot> {
99
100
  for (const filename of MAIN_CONFIG_FILENAMES) {
100
101
  const configPath = path.join(agentDir, filename);
101
102
  try {
102
- const raw = await Bun.file(configPath).text();
103
+ const raw = await fs.readFile(configPath, "utf8");
103
104
  const parsed = YAML.parse(raw);
104
105
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
105
106
  const record = parsed as Record<string, unknown>;
@@ -121,7 +122,8 @@ export async function loadAuthBrokerAccountPool(): Promise<AuthBrokerAccountPool
121
122
 
122
123
  let parsed: unknown;
123
124
  try {
124
- parsed = await Bun.file(filePath).json();
125
+ const raw = await fs.readFile(filePath, "utf8");
126
+ parsed = JSON.parse(raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw);
125
127
  } catch (error) {
126
128
  throw new AIError.ConfigurationError(`Unable to read OMP_AUTH_BROKER_ACCOUNT_POOL_FILE at ${filePath}`, {
127
129
  cause: error,
package/src/auth-retry.ts CHANGED
@@ -43,6 +43,9 @@ export type ApiKeyResolver = (ctx: ApiKeyResolveContext) => Promise<string | und
43
43
  /** A static bearer string, or a {@link ApiKeyResolver} that mints/rotates one. */
44
44
  export type ApiKey = string | ApiKeyResolver;
45
45
 
46
+ /** Keyless-provider credential marker; transports must not send it in authentication headers. */
47
+ export const NO_AUTH_SENTINEL = "N/A";
48
+
46
49
  /** Narrows {@link ApiKey} to its resolver form. */
47
50
  export function isApiKeyResolver(key: ApiKey | undefined): key is ApiKeyResolver {
48
51
  return typeof key === "function";
@@ -4707,7 +4707,7 @@ export class AuthStorage {
4707
4707
  async #resolveCredentialTarget(
4708
4708
  provider: string,
4709
4709
  sessionId: string | undefined,
4710
- options?: { credentialId?: number; apiKey?: string },
4710
+ options?: { credentialId?: number; apiKey?: string; allowStaleOAuthBearer?: boolean },
4711
4711
  ): Promise<{ type: AuthCredential["type"]; index: number; explicit: boolean } | undefined> {
4712
4712
  const explicit = options?.credentialId !== undefined || options?.apiKey !== undefined;
4713
4713
  if (explicit) {
@@ -4731,6 +4731,15 @@ export class AuthStorage {
4731
4731
  return { type: entry.credential.type, index, explicit: true };
4732
4732
  }
4733
4733
  }
4734
+ // Quota and account policy survive token refresh; hard auth failures do not.
4735
+ if (options.allowStaleOAuthBearer && options.credentialId === undefined) {
4736
+ const credentialId = this.#findOAuthCredentialIdForBearer(provider, options.apiKey);
4737
+ const index =
4738
+ credentialId === undefined
4739
+ ? -1
4740
+ : stored.findIndex(entry => entry.id === credentialId && entry.credential.type === "oauth");
4741
+ if (index >= 0) return { type: "oauth", index, explicit: true };
4742
+ }
4734
4743
  }
4735
4744
  if (explicit) return undefined;
4736
4745
  const sessionCredential = this.#getSessionCredential(provider, sessionId);
@@ -4866,23 +4875,11 @@ export class AuthStorage {
4866
4875
  },
4867
4876
  ): Promise<UsageLimitMarkResult> {
4868
4877
  await this.#adoptExternalCredentialChanges();
4869
- let sessionCredential = await this.#resolveCredentialTarget(provider, sessionId, {
4878
+ const sessionCredential = await this.#resolveCredentialTarget(provider, sessionId, {
4870
4879
  credentialId: options?.credentialId,
4871
4880
  apiKey: options?.apiKey,
4881
+ allowStaleOAuthBearer: true,
4872
4882
  });
4873
- if (!sessionCredential && options?.credentialId === undefined && options?.apiKey !== undefined) {
4874
- // Account quota survives OAuth bearer rotation. Attribute a delayed
4875
- // usage-limit response through the durable row id captured when this
4876
- // exact bearer was resolved; never use this alias for hard auth errors.
4877
- const credentialId = this.#findOAuthCredentialIdForBearer(provider, options.apiKey);
4878
- const index =
4879
- credentialId === undefined
4880
- ? -1
4881
- : this.#getStoredCredentials(provider).findIndex(
4882
- entry => entry.id === credentialId && entry.credential.type === "oauth",
4883
- );
4884
- if (index >= 0) sessionCredential = { type: "oauth", index, explicit: true };
4885
- }
4886
4883
  if (!sessionCredential) return { switched: false };
4887
4884
  const target = this.#getStoredCredentials(provider)[sessionCredential.index];
4888
4885
  if (!target || target.credential.type !== sessionCredential.type) return { switched: false };
@@ -6974,8 +6971,8 @@ export class AuthStorage {
6974
6971
  * stale session stickiness. Fall back to the session-sticky credential only
6975
6972
  * when neither explicit target is available. For hard-auth errors, an explicit
6976
6973
  * target that no longer matches storage returns `false` without mutation.
6977
- * Delayed usage-limit errors may instead recover the durable OAuth row from
6978
- * the bearer fingerprint recorded when the request resolved.
6974
+ * Delayed usage-limit and account-policy errors may instead recover the durable
6975
+ * OAuth row from the bearer fingerprint recorded when the request resolved.
6979
6976
  *
6980
6977
  * - usage-limit / account-rate-limit error → {@link AuthStorage.markUsageLimitReached}
6981
6978
  * (temporary block via its own backoff — default plus server usage-report
@@ -7024,16 +7021,16 @@ export class AuthStorage {
7024
7021
  ).switched;
7025
7022
  }
7026
7023
 
7024
+ const deniedModel = AIError.codexChatGPTAccountPolicyModel(error);
7025
+ const exactCodexModelPolicy =
7026
+ deniedModel !== undefined && AIError.isCodexChatGPTAccountPolicyError(error, provider, options?.modelId);
7027
+ const exactModelPolicy = exactCodexModelPolicy || exactCursorModelPolicy;
7027
7028
  const sessionCredential = await this.#resolveCredentialTarget(provider, sessionId, {
7028
7029
  credentialId: options?.credentialId,
7029
7030
  apiKey: options?.apiKey,
7031
+ allowStaleOAuthBearer: accountPolicy || exactModelPolicy,
7030
7032
  });
7031
7033
  if (!sessionCredential) return false;
7032
-
7033
- const deniedModel = AIError.codexChatGPTAccountPolicyModel(error);
7034
- const exactCodexModelPolicy =
7035
- deniedModel !== undefined && AIError.isCodexChatGPTAccountPolicyError(error, provider, options?.modelId);
7036
- const exactModelPolicy = exactCodexModelPolicy || exactCursorModelPolicy;
7037
7034
  // The exact sentence is provider-controlled input. A non-Codex provider,
7038
7035
  // absent request model, or mismatched model must not turn it into either a
7039
7036
  // global block or a hard-auth invalidation.
@@ -7049,6 +7046,15 @@ export class AuthStorage {
7049
7046
  options?.modelId,
7050
7047
  modelPolicyScope,
7051
7048
  );
7049
+ // Account-wide denials must not inherit a quota scope that healthy usage can heal.
7050
+ routing.blockScope = modelPolicyScope;
7051
+ const sticky = this.#getSessionCredential(provider, sessionId);
7052
+ if (
7053
+ !sessionCredential.explicit ||
7054
+ (sticky?.type === sessionCredential.type && sticky.index === sessionCredential.index)
7055
+ ) {
7056
+ this.#clearSessionCredential(provider, sessionId);
7057
+ }
7052
7058
  return this.#blockCredentialForRotation(
7053
7059
  provider,
7054
7060
  sessionCredential.type,
@@ -41,8 +41,16 @@ export function isAuthRetryableError(error: unknown): boolean {
41
41
  if (isUsageLimit(error)) return true;
42
42
  if (isAccountPolicyError(error)) return true;
43
43
  if (isInvalidatedOAuthTokenError(error)) return true;
44
- const httpStatus = extractHttpStatusFromError(error);
45
- const message = error instanceof Error ? error.message : typeof error === "string" ? error : undefined;
44
+ let httpStatus = extractHttpStatusFromError(error);
45
+ let message = error instanceof Error ? error.message : typeof error === "string" ? error : undefined;
46
+ if (typeof error === "object" && error !== null) {
47
+ if (httpStatus === undefined && "errorStatus" in error && typeof error.errorStatus === "number") {
48
+ httpStatus = error.errorStatus;
49
+ }
50
+ if (message === undefined && "errorMessage" in error && typeof error.errorMessage === "string") {
51
+ message = error.errorMessage;
52
+ }
53
+ }
46
54
  const embeddedStatus = message ? extractHttpStatusFromError({ message }) : undefined;
47
55
  const status = httpStatus ?? embeddedStatus;
48
56
  if (isConcurrencyCapExclusion(status, message)) return false;
@@ -90,8 +90,8 @@ export class AnthropicApiError extends ProviderHttpError {
90
90
  declare readonly headers: Headers;
91
91
  readonly requestId: string | null;
92
92
 
93
- constructor(status: number, message: string, headers: Headers) {
94
- super(message, status, { headers });
93
+ constructor(status: number, message: string, headers: Headers, options?: { code?: string; cause?: unknown }) {
94
+ super(message, status, { headers, code: options?.code, cause: options?.cause });
95
95
  this.name = "AnthropicApiError";
96
96
  this.requestId = headers.get("request-id");
97
97
  }
@@ -176,7 +176,27 @@ export class AnthropicApiError extends ProviderHttpError {
176
176
  }
177
177
 
178
178
  const detail = bodyChunks.join("").trim() || "status code (no body)";
179
- return new AnthropicApiError(response.status, `${response.status} ${detail}`, response.headers);
179
+ let code: string | undefined;
180
+ try {
181
+ const parsed: unknown = JSON.parse(detail);
182
+ if (parsed && typeof parsed === "object" && "error" in parsed) {
183
+ const err = parsed.error;
184
+ if (err && typeof err === "object") {
185
+ if (
186
+ "details" in err &&
187
+ err.details &&
188
+ typeof err.details === "object" &&
189
+ "error_code" in err.details &&
190
+ typeof err.details.error_code === "string"
191
+ ) {
192
+ code = err.details.error_code;
193
+ } else if ("type" in err && typeof err.type === "string") {
194
+ code = err.type;
195
+ }
196
+ }
197
+ }
198
+ } catch {}
199
+ return new AnthropicApiError(response.status, `${response.status} ${detail}`, response.headers, { code });
180
200
  }
181
201
  }
182
202
 
@@ -1,5 +1,5 @@
1
1
  import { isUnexpectedSocketCloseMessage } from "@oh-my-pi/pi-utils/fetch-retry";
2
- import type { Api, AssistantMessage } from "../types";
2
+ import type { Api, AssistantMessage, Usage } from "../types";
3
3
  import { AwsCredentialsError } from "./aws";
4
4
  import {
5
5
  AnthropicConnectionError,
@@ -194,6 +194,17 @@ const PROVIDER_FINISH_ERROR_PATTERN = /\bProvider (?:returned error finish_reaso
194
194
  const EMPTY_RESPONSE_PATTERN = /\bthought-only response without final output\b/i;
195
195
  const CONTENT_FILTER_PATTERN = /\b(?:incomplete:\s*)?content_filter\b/i;
196
196
  const ACCOUNT_POLICY_PATTERN = /\bcyber_policy\b|trusted access for cyber/i;
197
+ export const ANTHROPIC_ACCOUNT_POLICY_PATTERN =
198
+ /\b(?:oauth_not_allowed_for_organization|permission_error)\b|\bOAuth authentication is currently not allowed for this organization\b/i;
199
+
200
+ /** Whether an error message represents an Anthropic account-scoped permission/policy denial. */
201
+ export function isAnthropicAccountPolicyText(text: string, provider?: string, statusArg?: number): boolean {
202
+ if (provider !== undefined && provider !== "anthropic") return false;
203
+ const statusCandidate = statusArg ?? (text ? status({ message: text }) : undefined);
204
+ if (statusCandidate !== undefined && statusCandidate !== 403) return false;
205
+ return ANTHROPIC_ACCOUNT_POLICY_PATTERN.test(text);
206
+ }
207
+
197
208
  const CODEX_CHATGPT_ACCOUNT_MODEL_POLICY_PATTERN =
198
209
  /\bThe ['"]([^'"\r\n]+)['"] model is not supported when using Codex with a ChatGPT account\./i;
199
210
  const CODEX_CHATGPT_ACCOUNT_MODEL_MAX_LENGTH = 256;
@@ -381,6 +392,9 @@ function statusInternal(error: unknown, depth: number): number | undefined {
381
392
  if (typeof errObj.statusCode === "number" && errObj.statusCode >= 100 && errObj.statusCode <= 599) {
382
393
  return errObj.statusCode;
383
394
  }
395
+ if (typeof errObj.errorStatus === "number" && errObj.errorStatus >= 100 && errObj.errorStatus <= 599) {
396
+ return errObj.errorStatus;
397
+ }
384
398
  if (typeof errObj.response === "object" && errObj.response !== null) {
385
399
  const resp = errObj.response as Record<string, unknown>;
386
400
  if (typeof resp.status === "number" && resp.status >= 100 && resp.status <= 599) {
@@ -490,6 +504,8 @@ function classifyText(
490
504
  if (isProviderFinishErrorText(errorMessage)) kinds |= Flag.ProviderFinishError;
491
505
  if (EMPTY_RESPONSE_PATTERN.test(errorMessage)) kinds |= Flag.EmptyResponse | Flag.Transient;
492
506
  if (isContentBlockedText(errorMessage)) kinds |= Flag.ContentBlocked;
507
+ const statusClean = errorStatus ? errorStatus : (status({ message: errorMessage }) ?? undefined);
508
+
493
509
  if (
494
510
  ACCOUNT_POLICY_PATTERN.test(errorMessage) ||
495
511
  isCodexChatGPTAccountPolicyText(errorMessage, provider, modelId) ||
@@ -497,9 +513,10 @@ function classifyText(
497
513
  ) {
498
514
  kinds |= Flag.AccountPolicy | Flag.ContentBlocked;
499
515
  }
516
+ if (isAnthropicAccountPolicyText(errorMessage, provider, statusClean)) {
517
+ kinds |= Flag.AccountPolicy;
518
+ }
500
519
  if (isAuthFailureText(errorMessage)) kinds |= Flag.AuthFailed;
501
-
502
- const statusClean = errorStatus ? errorStatus : (status({ message: errorMessage }) ?? undefined);
503
520
  const cleanMessage = errorMessage;
504
521
  const isOpaque = isOpaqueStatusBody(cleanMessage);
505
522
 
@@ -597,8 +614,12 @@ export function classify(error: unknown, api?: Api): number {
597
614
  if ("errorId" in link && typeof (link as { errorId: unknown }).errorId === "number") {
598
615
  kinds |= (link as { errorId: number }).errorId & KIND_MASK;
599
616
  }
600
- if ("code" in link && typeof link.code === "string" && ACCOUNT_POLICY_PATTERN.test(link.code)) {
601
- kinds |= Flag.AccountPolicy | Flag.ContentBlocked;
617
+ if ("code" in link && typeof link.code === "string") {
618
+ if (ACCOUNT_POLICY_PATTERN.test(link.code)) {
619
+ kinds |= Flag.AccountPolicy | Flag.ContentBlocked;
620
+ } else if (ANTHROPIC_ACCOUNT_POLICY_PATTERN.test(link.code)) {
621
+ kinds |= Flag.AccountPolicy;
622
+ }
602
623
  }
603
624
  }
604
625
 
@@ -635,6 +656,13 @@ export function classify(error: unknown, api?: Api): number {
635
656
  if (code === "overloaded_error" || code === "rate_limit_error") {
636
657
  linkKinds |= Flag.Transient;
637
658
  }
659
+ if (
660
+ code === "oauth_not_allowed_for_organization" ||
661
+ code === "permission_error" ||
662
+ (codeStatus === 403 && ANTHROPIC_ACCOUNT_POLICY_PATTERN.test(link.message))
663
+ ) {
664
+ linkKinds |= Flag.AccountPolicy;
665
+ }
638
666
  if (
639
667
  (codeStatus === 401 || codeStatus === 403) &&
640
668
  !(codeStatus === 403 && parseRateLimitReason(link.message) === "CONCURRENT_LIMIT")
@@ -655,12 +683,12 @@ export function classify(error: unknown, api?: Api): number {
655
683
  linkMessage = link.message;
656
684
  } else if (typeof link === "string") {
657
685
  linkMessage = link;
658
- } else if (
659
- typeof link === "object" &&
660
- "message" in link &&
661
- typeof (link as { message: unknown }).message === "string"
662
- ) {
663
- linkMessage = (link as { message: string }).message;
686
+ } else if (typeof link === "object") {
687
+ if ("message" in link && typeof link.message === "string") {
688
+ linkMessage = link.message;
689
+ } else if ("errorMessage" in link && typeof link.errorMessage === "string") {
690
+ linkMessage = link.errorMessage;
691
+ }
664
692
  }
665
693
 
666
694
  const linkStatus = status(link);
@@ -834,14 +862,21 @@ export function attach<E extends object>(error: E, id: number): E {
834
862
  return error;
835
863
  }
836
864
 
865
+ /** Overflow-classification evidence, including errors received before token usage is available. */
866
+ export interface ContextOverflowMessage extends Pick<AssistantMessage, "errorId" | "stopReason" | "errorMessage"> {
867
+ readonly usage?: Pick<Usage, "input" | "cacheRead" | "cacheWrite">;
868
+ }
869
+
837
870
  /** Provider-reported usage proves context-window excess — authoritative, compaction-owned (#9235). */
838
- export function isUsageBackedContextOverflow(message: AssistantMessage, contextWindow?: number): boolean {
839
- if (!contextWindow) return false;
840
- const inputTokens = message.usage.input + message.usage.cacheRead + message.usage.cacheWrite;
871
+ export function isUsageBackedContextOverflow(message: ContextOverflowMessage, contextWindow?: number): boolean {
872
+ const usage = message.usage;
873
+ if (!contextWindow || !usage) return false;
874
+ const inputTokens = usage.input + usage.cacheRead + usage.cacheWrite;
841
875
  return inputTokens > contextWindow;
842
876
  }
843
877
 
844
- export function isContextOverflow(message: AssistantMessage, contextWindow?: number): boolean {
878
+ /** Classify overflow from error flags, available token usage, or provider error text. */
879
+ export function isContextOverflow(message: ContextOverflowMessage, contextWindow?: number): boolean {
845
880
  if (is(message.errorId, Flag.ContextOverflow)) return true;
846
881
  if (isUsageBackedContextOverflow(message, contextWindow)) return true;
847
882
  return message.stopReason === "error" && !!message.errorMessage && matchesOverflowText(message.errorMessage);
@@ -861,7 +896,7 @@ export function isPayloadRejection(message: AssistantMessage): boolean {
861
896
  * Usage-backed overflows are authoritative window excesses and never ambiguous. */
862
897
  export function isTextAmbiguousContextOverflow(
863
898
  errorId: number,
864
- message: AssistantMessage | undefined,
899
+ message: ContextOverflowMessage | undefined,
865
900
  contextWindow?: number,
866
901
  ): boolean {
867
902
  const overflowFlagged =
package/src/index.ts CHANGED
@@ -10,22 +10,25 @@ export * from "./judgment";
10
10
  export * from "./oneshot-retry";
11
11
  export * from "./provider-details";
12
12
  export * from "./provider-session-state";
13
- export * from "./providers/anthropic";
14
- export * from "./providers/anthropic-client";
15
- export * from "./providers/azure-openai-responses";
13
+ // Provider-specific runtime APIs are imported from their providers/* subpaths.
14
+ export type * from "./providers/anthropic";
15
+ export * from "./providers/anthropic-identity";
16
+ export * from "./providers/anthropic-state";
17
+ export type * from "./providers/anthropic-client";
18
+ export type * from "./providers/azure-openai-responses";
16
19
  export type * from "./providers/cursor";
17
- export * from "./providers/gitlab-duo";
18
- export * from "./providers/gitlab-duo-workflow";
20
+ export type * from "./providers/gitlab-duo";
21
+ export type * from "./providers/gitlab-duo-workflow";
19
22
  export type * from "./providers/google";
20
23
  export type * from "./providers/google-gemini-cli";
21
24
  export type * from "./providers/google-vertex";
22
- export * from "./providers/kimi";
23
- export * from "./providers/mock";
24
- export * from "./providers/ollama";
25
- export * from "./providers/openai-codex-responses";
26
- export * from "./providers/openai-completions";
27
- export * from "./providers/openai-responses";
28
- export * from "./providers/synthetic";
25
+ export type * from "./providers/kimi";
26
+ export type * from "./providers/mock";
27
+ export type * from "./providers/ollama";
28
+ export type * from "./providers/openai-codex-responses";
29
+ export type * from "./providers/openai-completions";
30
+ export type * from "./providers/openai-responses";
31
+ export type * from "./providers/synthetic";
29
32
  export * from "./registry";
30
33
  export * from "./stream";
31
34
  export * from "./types";
@@ -115,14 +115,17 @@ export class JudgmentParseError extends Error {
115
115
  }
116
116
  }
117
117
 
118
- /** Zero-cost usage for a request whose backend reports only token counts. */
119
- export function tokenUsage(input: number, output: number): Usage {
118
+ /**
119
+ * Usage from a backend that reports token counts and, optionally, one billed
120
+ * USD amount. Judgment pricing is input-only, so the amount lands on `input`.
121
+ */
122
+ export function tokenUsage(input: number, output: number, cost = 0): Usage {
120
123
  return {
121
124
  input,
122
125
  output,
123
126
  cacheRead: 0,
124
127
  cacheWrite: 0,
125
128
  totalTokens: input + output,
126
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
129
+ cost: { input: cost, output: 0, cacheRead: 0, cacheWrite: 0, total: cost },
127
130
  };
128
131
  }
@@ -1,16 +1,20 @@
1
1
  /**
2
2
  * TypeSafe System One client: the native {@link Judge} backend.
3
3
  *
4
- * Forwards a {@link JudgmentRequest} verbatim to `POST /v1/systemone` and maps
5
- * the typed answers back. Credentials flow through {@link withAuth}, so a
6
- * stored key rotates on 401/403 exactly like chat providers; transient
7
- * 429/5xx responses retry with bounded, `retry-after`-aware backoff.
4
+ * Forwards a {@link JudgmentRequest} verbatim to the judgment route of its
5
+ * API ({@link JUDGMENT_ROUTES}) and maps the typed answers back. TypeSafe's
6
+ * own `POST /v1/systemone` and OpenRouter's `POST /api/alpha/decisions` share
7
+ * the request and answer wire shape, so one client serves both. Credentials
8
+ * flow through {@link withAuth}, so a stored key rotates on 401/403 exactly
9
+ * like chat providers; transient 429/5xx responses retry with bounded,
10
+ * `retry-after`-aware backoff.
8
11
  *
9
12
  * Environment (mirrors the official SDK): `TYPESAFE_API_KEY` is resolved by
10
13
  * the auth registry (`rules/auth/typesafe.kdl`), `TYPESAFE_BASE_URL`
11
14
  * overrides the API root, `TYPESAFE_DEFAULT_MODEL` the model.
12
15
  */
13
- import type { FetchImpl } from "@oh-my-pi/pi-catalog/types";
16
+ import { TYPESAFE_DEFAULT_BASE_URL } from "@oh-my-pi/pi-catalog/discovery";
17
+ import type { Api, FetchImpl } from "@oh-my-pi/pi-catalog/types";
14
18
  import { $env } from "@oh-my-pi/pi-utils";
15
19
  import { type ApiKey, withAuth } from "../auth-retry";
16
20
  import * as AIError from "../error";
@@ -26,9 +30,22 @@ import {
26
30
  } from "./types";
27
31
 
28
32
  export const TYPESAFE_PROVIDER = "typesafe";
29
- export const TYPESAFE_DEFAULT_BASE_URL = "https://api.typesafe.ai";
30
33
  export const TYPESAFE_DEFAULT_MODEL = "jev-latest";
31
34
 
35
+ /** Judgment `POST` path under a model's base URL, per System One–compatible API. */
36
+ export const JUDGMENT_ROUTES = {
37
+ typesafe: "/v1/systemone",
38
+ "openrouter-decisions": "/decisions",
39
+ } as const satisfies Partial<Record<Api, string>>;
40
+
41
+ /** APIs {@link TypeSafeJudge} can serve. */
42
+ export type JudgmentApi = keyof typeof JUDGMENT_ROUTES;
43
+
44
+ /** Whether a catalog API answers System One judgments natively. */
45
+ export function isJudgmentApi(api: Api): api is JudgmentApi {
46
+ return Object.hasOwn(JUDGMENT_ROUTES, api);
47
+ }
48
+
32
49
  /** `TYPESAFE_BASE_URL` when set, else the public API root; trailing slashes stripped. */
33
50
  export function typesafeBaseUrl(): string {
34
51
  return ($env.TYPESAFE_BASE_URL?.trim() || TYPESAFE_DEFAULT_BASE_URL).replace(/\/+$/, "");
@@ -41,6 +58,10 @@ export function typesafeModel(): string {
41
58
 
42
59
  export interface TypeSafeJudgeOptions {
43
60
  apiKey: ApiKey;
61
+ /** Wire route; defaults to TypeSafe's own API. */
62
+ api?: JudgmentApi;
63
+ /** Catalog provider reported on results; defaults to {@link TYPESAFE_PROVIDER}. */
64
+ provider?: string;
44
65
  /** Defaults to {@link typesafeBaseUrl}. */
45
66
  baseUrl?: string;
46
67
  /** Defaults to {@link typesafeModel}. */
@@ -60,17 +81,11 @@ const MAX_ATTEMPTS = 3;
60
81
  const BACKOFF_BASE_MS = 500;
61
82
  const BACKOFF_MAX_MS = 5_000;
62
83
 
63
- /** Wire shape of `GET /v1/models`. */
64
- export interface TypeSafeModelCard {
65
- name: string;
66
- description: string;
67
- release_date: string;
68
- }
69
-
70
84
  interface SystemOneResponse {
71
85
  model: string;
72
86
  answers: Record<string, Answer>;
73
- usage: { input_tokens: number; output_tokens: number };
87
+ /** OpenRouter adds the billed `cost` in USD; TypeSafe reports tokens only. */
88
+ usage: { input_tokens: number; output_tokens: number; cost?: number };
74
89
  }
75
90
 
76
91
  /** Server hint wins (capped); otherwise exponential backoff from {@link BACKOFF_BASE_MS}. */
@@ -82,6 +97,8 @@ function backoffMs(attempt: number, headers: Headers | undefined): number {
82
97
 
83
98
  export class TypeSafeJudge implements Judge {
84
99
  readonly label: string;
100
+ readonly api: JudgmentApi;
101
+ readonly provider: string;
85
102
  readonly model: string;
86
103
  readonly baseUrl: string;
87
104
  readonly #apiKey: ApiKey;
@@ -90,67 +107,55 @@ export class TypeSafeJudge implements Judge {
90
107
 
91
108
  constructor(options: TypeSafeJudgeOptions) {
92
109
  this.#apiKey = options.apiKey;
110
+ this.api = options.api ?? TYPESAFE_PROVIDER;
111
+ this.provider = options.provider ?? TYPESAFE_PROVIDER;
93
112
  this.baseUrl = (options.baseUrl ?? typesafeBaseUrl()).replace(/\/+$/, "");
94
113
  this.model = options.model ?? typesafeModel();
95
114
  this.#fetch = options.fetch ?? fetch;
96
115
  this.#timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
97
- this.label = `${TYPESAFE_PROVIDER}/${this.model}`;
116
+ this.label = `${this.provider}/${this.model}`;
98
117
  }
99
118
 
100
119
  async judge<Q extends Questions>(request: JudgmentRequest<Q>, options?: JudgeOptions): Promise<JudgmentResult<Q>> {
101
120
  const body = JSON.stringify({ state: request.state, model: this.model, questions: request.questions });
102
- const response = await this.#request<SystemOneResponse>("POST", "/v1/systemone", body, options?.signal);
121
+ const signal = options?.signal;
122
+ const response = await withAuth(
123
+ this.#apiKey,
124
+ key => this.#attempt<SystemOneResponse>(JUDGMENT_ROUTES[this.api], body, key, signal),
125
+ { signal },
126
+ );
103
127
  for (const id in request.questions) {
104
128
  const answer = response.answers[id];
105
129
  if (answer === undefined || answer.type !== request.questions[id].type) {
106
130
  throw new AIError.ProviderResponseError(
107
- `TypeSafe response is missing a "${request.questions[id].type}" answer for question "${id}"`,
108
- { provider: TYPESAFE_PROVIDER, kind: "envelope" },
131
+ `${this.label} response is missing a "${request.questions[id].type}" answer for question "${id}"`,
132
+ { provider: this.provider, kind: "envelope" },
109
133
  );
110
134
  }
111
135
  }
112
136
  return {
113
- api: TYPESAFE_PROVIDER,
114
- provider: TYPESAFE_PROVIDER,
137
+ api: this.api,
138
+ provider: this.provider,
115
139
  model: response.model,
116
140
  answers: response.answers as JudgmentResult<Q>["answers"],
117
- usage: tokenUsage(response.usage.input_tokens, response.usage.output_tokens),
141
+ usage: tokenUsage(response.usage.input_tokens, response.usage.output_tokens, response.usage.cost),
118
142
  };
119
143
  }
120
144
 
121
- /** Models available to the account (`GET /v1/models`); also the login validation probe. */
122
- async listModels(signal?: AbortSignal): Promise<TypeSafeModelCard[]> {
123
- const response = await this.#request<{ models: TypeSafeModelCard[] }>("GET", "/v1/models", undefined, signal);
124
- if (!Array.isArray(response.models)) {
125
- throw new AIError.ProviderResponseError("TypeSafe /v1/models response is missing `models`", {
126
- provider: TYPESAFE_PROVIDER,
127
- kind: "envelope",
128
- });
129
- }
130
- return response.models;
131
- }
132
-
133
- async #request<T>(method: "GET" | "POST", path: string, body: string | undefined, signal?: AbortSignal): Promise<T> {
134
- return withAuth(this.#apiKey, key => this.#attempt<T>(method, path, body, key, signal), { signal });
135
- }
136
-
137
- async #attempt<T>(
138
- method: "GET" | "POST",
139
- path: string,
140
- body: string | undefined,
141
- key: string,
142
- signal: AbortSignal | undefined,
143
- ): Promise<T> {
145
+ async #attempt<T>(path: string, body: string, key: string, signal: AbortSignal | undefined): Promise<T> {
144
146
  const url = `${this.baseUrl}${path}`;
145
- const headers: Record<string, string> = { Authorization: `Bearer ${key}`, Accept: "application/json" };
146
- if (body !== undefined) headers["Content-Type"] = "application/json";
147
+ const headers: Record<string, string> = {
148
+ Authorization: `Bearer ${key}`,
149
+ Accept: "application/json",
150
+ "Content-Type": "application/json",
151
+ };
147
152
  for (let attempt = 0; ; attempt++) {
148
153
  signal?.throwIfAborted();
149
154
  const timeout = AbortSignal.timeout(this.#timeoutMs);
150
155
  let response: Response;
151
156
  try {
152
157
  response = await this.#fetch(url, {
153
- method,
158
+ method: "POST",
154
159
  headers,
155
160
  body,
156
161
  signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
@@ -162,7 +167,7 @@ export class TypeSafeJudge implements Judge {
162
167
  }
163
168
  if (response.ok) return (await response.json()) as T;
164
169
  const text = await response.text();
165
- const error = new TypeSafeApiError(`TypeSafe API error (${response.status}): ${text}`, response.status, {
170
+ const error = new TypeSafeApiError(`${this.label} API error (${response.status}): ${text}`, response.status, {
166
171
  headers: response.headers,
167
172
  });
168
173
  const transient = response.status === 408 || response.status === 429 || response.status >= 500;
@@ -38,7 +38,7 @@
38
38
  * very turn that rotated; it stays on its existing session-close path.
39
39
  */
40
40
 
41
- import { clearAnthropicFastModeFallback } from "./providers/anthropic";
41
+ import { clearAnthropicFastModeFallback } from "./providers/anthropic-state";
42
42
  import { resetOpenAIResponsesAccountScopedState } from "./providers/openai-responses";
43
43
  import type { ProviderSessionState } from "./types";
44
44