@oh-my-pi/pi-ai 18.2.6 → 18.2.8

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 (136) hide show
  1. package/CHANGELOG.md +29 -1
  2. package/THIRD-PARTY-NOTICES.txt +0 -37
  3. package/dist/types/auth-gateway/dispatch.d.ts +80 -0
  4. package/dist/types/auth-gateway/http.d.ts +5 -6
  5. package/dist/types/auth-gateway/index.d.ts +1 -0
  6. package/dist/types/auth-gateway/routes/embeddings.d.ts +3 -0
  7. package/dist/types/auth-gateway/routes/images.d.ts +3 -0
  8. package/dist/types/auth-gateway/routes/rerank.d.ts +3 -0
  9. package/dist/types/auth-gateway/routes/speech.d.ts +2 -0
  10. package/dist/types/auth-gateway/routes/systemone.d.ts +2 -0
  11. package/dist/types/auth-gateway/routes/transcriptions.d.ts +3 -0
  12. package/dist/types/auth-gateway/routes/video.d.ts +7 -0
  13. package/dist/types/auth-gateway/server.d.ts +10 -16
  14. package/dist/types/auth-retry.d.ts +2 -0
  15. package/dist/types/auth-storage.d.ts +2 -2
  16. package/dist/types/embeddings/index.d.ts +7 -0
  17. package/dist/types/embeddings/openai-embeddings.d.ts +15 -0
  18. package/dist/types/embeddings/types.d.ts +15 -0
  19. package/dist/types/error/classes.d.ts +4 -1
  20. package/dist/types/error/flags.d.ts +12 -4
  21. package/dist/types/images/google-antigravity.d.ts +9 -0
  22. package/dist/types/images/google-generative-ai.d.ts +3 -0
  23. package/dist/types/images/index.d.ts +14 -0
  24. package/dist/types/images/openai-hosted.d.ts +3 -0
  25. package/dist/types/images/openai-images.d.ts +5 -0
  26. package/dist/types/images/openrouter-images.d.ts +3 -0
  27. package/dist/types/images/shared.d.ts +34 -0
  28. package/dist/types/images/types.d.ts +31 -0
  29. package/dist/types/index.d.ts +21 -13
  30. package/dist/types/judgment/types.d.ts +5 -2
  31. package/dist/types/judgment/typesafe.d.ts +18 -22
  32. package/dist/types/providers/anthropic-compaction.d.ts +10 -0
  33. package/dist/types/providers/anthropic-identity.d.ts +18 -0
  34. package/dist/types/providers/anthropic-state.d.ts +13 -0
  35. package/dist/types/providers/anthropic.d.ts +6 -59
  36. package/dist/types/providers/embeddings-server.d.ts +32 -0
  37. package/dist/types/providers/gitlab-duo.d.ts +0 -1
  38. package/dist/types/providers/images-server.d.ts +22 -0
  39. package/dist/types/providers/kimi.d.ts +1 -5
  40. package/dist/types/providers/openai-codex-attestation.d.ts +6 -0
  41. package/dist/types/providers/openai-codex-compaction.d.ts +6 -0
  42. package/dist/types/providers/openai-codex-responses.d.ts +3 -27
  43. package/dist/types/providers/openai-codex-transport.d.ts +7 -0
  44. package/dist/types/providers/openai-shared.d.ts +0 -9
  45. package/dist/types/providers/register-builtins.d.ts +20 -22
  46. package/dist/types/providers/rerank-server.d.ts +35 -0
  47. package/dist/types/providers/speech-server.d.ts +8 -0
  48. package/dist/types/providers/synthetic.d.ts +1 -5
  49. package/dist/types/providers/systemone-server.d.ts +26 -0
  50. package/dist/types/providers/transcriptions-server.d.ts +32 -0
  51. package/dist/types/providers/video-server.d.ts +39 -0
  52. package/dist/types/rerank/index.d.ts +7 -0
  53. package/dist/types/rerank/openrouter-rerank.d.ts +15 -0
  54. package/dist/types/rerank/types.d.ts +17 -0
  55. package/dist/types/speech/index.d.ts +13 -0
  56. package/dist/types/speech/openai-speech.d.ts +3 -0
  57. package/dist/types/speech/transport.d.ts +7 -0
  58. package/dist/types/speech/types.d.ts +24 -0
  59. package/dist/types/speech/xai-tts.d.ts +7 -0
  60. package/dist/types/transcription/index.d.ts +7 -0
  61. package/dist/types/transcription/openai-transcriptions.d.ts +15 -0
  62. package/dist/types/transcription/types.d.ts +41 -0
  63. package/dist/types/video/index.d.ts +11 -0
  64. package/dist/types/video/openrouter-video.d.ts +19 -0
  65. package/dist/types/video/types.d.ts +62 -0
  66. package/package.json +30 -6
  67. package/src/auth-gateway/dispatch.ts +273 -0
  68. package/src/auth-gateway/http.ts +6 -7
  69. package/src/auth-gateway/index.ts +1 -0
  70. package/src/auth-gateway/routes/embeddings.ts +98 -0
  71. package/src/auth-gateway/routes/images.ts +131 -0
  72. package/src/auth-gateway/routes/rerank.ts +87 -0
  73. package/src/auth-gateway/routes/speech.ts +101 -0
  74. package/src/auth-gateway/routes/systemone.ts +116 -0
  75. package/src/auth-gateway/routes/transcriptions.ts +98 -0
  76. package/src/auth-gateway/routes/video.ts +243 -0
  77. package/src/auth-gateway/server.ts +123 -260
  78. package/src/auth-retry.ts +3 -0
  79. package/src/auth-storage.ts +28 -22
  80. package/src/embeddings/index.ts +17 -0
  81. package/src/embeddings/openai-embeddings.ts +141 -0
  82. package/src/embeddings/types.ts +14 -0
  83. package/src/error/auth-classify.ts +10 -2
  84. package/src/error/classes.ts +23 -3
  85. package/src/error/flags.ts +51 -16
  86. package/src/error/rate-limit.ts +1 -1
  87. package/src/images/google-antigravity.ts +180 -0
  88. package/src/images/google-generative-ai.ts +92 -0
  89. package/src/images/index.ts +59 -0
  90. package/src/images/openai-hosted.ts +185 -0
  91. package/src/images/openai-images.ts +110 -0
  92. package/src/images/openrouter-images.ts +33 -0
  93. package/src/images/shared.ts +193 -0
  94. package/src/images/types.ts +36 -0
  95. package/src/index.ts +22 -13
  96. package/src/judgment/types.ts +6 -3
  97. package/src/judgment/typesafe.ts +58 -48
  98. package/src/provider-session-state.ts +1 -1
  99. package/src/providers/anthropic-compaction.ts +54 -0
  100. package/src/providers/anthropic-identity.ts +136 -0
  101. package/src/providers/anthropic-state.ts +55 -0
  102. package/src/providers/anthropic.ts +39 -296
  103. package/src/providers/bedrock-mantle.ts +1 -1
  104. package/src/providers/embeddings-server.ts +151 -0
  105. package/src/providers/gitlab-duo.ts +0 -4
  106. package/src/providers/images-server.ts +159 -0
  107. package/src/providers/kimi.ts +1 -8
  108. package/src/providers/openai-codex-attestation.ts +19 -0
  109. package/src/providers/openai-codex-compaction.ts +18 -0
  110. package/src/providers/openai-codex-responses.ts +9 -68
  111. package/src/providers/openai-codex-transport.ts +18 -0
  112. package/src/providers/openai-shared.ts +1 -10
  113. package/src/providers/register-builtins.ts +113 -320
  114. package/src/providers/rerank-server.ts +166 -0
  115. package/src/providers/speech-server.ts +53 -0
  116. package/src/providers/synthetic.ts +1 -8
  117. package/src/providers/systemone-server.ts +73 -0
  118. package/src/providers/transcriptions-server.ts +243 -0
  119. package/src/providers/video-server.ts +286 -0
  120. package/src/registry/cloudflare-ai-gateway.ts +1 -1
  121. package/src/rerank/index.ts +13 -0
  122. package/src/rerank/openrouter-rerank.ts +136 -0
  123. package/src/rerank/types.ts +20 -0
  124. package/src/speech/index.ts +35 -0
  125. package/src/speech/openai-speech.ts +26 -0
  126. package/src/speech/transport.ts +66 -0
  127. package/src/speech/types.ts +37 -0
  128. package/src/speech/xai-tts.ts +41 -0
  129. package/src/stream.ts +7 -15
  130. package/src/transcription/index.ts +17 -0
  131. package/src/transcription/openai-transcriptions.ts +133 -0
  132. package/src/transcription/types.ts +46 -0
  133. package/src/utils/anthropic-auth.ts +3 -6
  134. package/src/video/index.ts +34 -0
  135. package/src/video/openrouter-video.ts +210 -0
  136. package/src/video/types.ts +72 -0
@@ -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,10 +58,16 @@ 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}. */
47
68
  model?: string;
69
+ /** Static headers attached to judgment requests (e.g. proxy routing, gateway auth). */
70
+ headers?: Record<string, string>;
48
71
  fetch?: FetchImpl;
49
72
  /** Per-attempt timeout; defaults to {@link DEFAULT_TIMEOUT_MS}. */
50
73
  timeoutMs?: number;
@@ -60,17 +83,11 @@ const MAX_ATTEMPTS = 3;
60
83
  const BACKOFF_BASE_MS = 500;
61
84
  const BACKOFF_MAX_MS = 5_000;
62
85
 
63
- /** Wire shape of `GET /v1/models`. */
64
- export interface TypeSafeModelCard {
65
- name: string;
66
- description: string;
67
- release_date: string;
68
- }
69
-
70
86
  interface SystemOneResponse {
71
87
  model: string;
72
88
  answers: Record<string, Answer>;
73
- usage: { input_tokens: number; output_tokens: number };
89
+ /** OpenRouter adds the billed `cost` in USD; TypeSafe reports tokens only. */
90
+ usage: { input_tokens: number; output_tokens: number; cost?: number };
74
91
  }
75
92
 
76
93
  /** Server hint wins (capped); otherwise exponential backoff from {@link BACKOFF_BASE_MS}. */
@@ -82,75 +99,68 @@ function backoffMs(attempt: number, headers: Headers | undefined): number {
82
99
 
83
100
  export class TypeSafeJudge implements Judge {
84
101
  readonly label: string;
102
+ readonly api: JudgmentApi;
103
+ readonly provider: string;
85
104
  readonly model: string;
86
105
  readonly baseUrl: string;
87
106
  readonly #apiKey: ApiKey;
107
+ readonly #headers: Record<string, string> | undefined;
88
108
  readonly #fetch: FetchImpl;
89
109
  readonly #timeoutMs: number;
90
110
 
91
111
  constructor(options: TypeSafeJudgeOptions) {
92
112
  this.#apiKey = options.apiKey;
113
+ this.api = options.api ?? TYPESAFE_PROVIDER;
114
+ this.provider = options.provider ?? TYPESAFE_PROVIDER;
93
115
  this.baseUrl = (options.baseUrl ?? typesafeBaseUrl()).replace(/\/+$/, "");
94
116
  this.model = options.model ?? typesafeModel();
117
+ this.#headers = options.headers;
95
118
  this.#fetch = options.fetch ?? fetch;
96
119
  this.#timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
97
- this.label = `${TYPESAFE_PROVIDER}/${this.model}`;
120
+ this.label = `${this.provider}/${this.model}`;
98
121
  }
99
122
 
100
123
  async judge<Q extends Questions>(request: JudgmentRequest<Q>, options?: JudgeOptions): Promise<JudgmentResult<Q>> {
101
124
  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);
125
+ const signal = options?.signal;
126
+ const response = await withAuth(
127
+ this.#apiKey,
128
+ key => this.#attempt<SystemOneResponse>(JUDGMENT_ROUTES[this.api], body, key, signal),
129
+ { signal },
130
+ );
103
131
  for (const id in request.questions) {
104
132
  const answer = response.answers[id];
105
133
  if (answer === undefined || answer.type !== request.questions[id].type) {
106
134
  throw new AIError.ProviderResponseError(
107
- `TypeSafe response is missing a "${request.questions[id].type}" answer for question "${id}"`,
108
- { provider: TYPESAFE_PROVIDER, kind: "envelope" },
135
+ `${this.label} response is missing a "${request.questions[id].type}" answer for question "${id}"`,
136
+ { provider: this.provider, kind: "envelope" },
109
137
  );
110
138
  }
111
139
  }
112
140
  return {
113
- api: TYPESAFE_PROVIDER,
114
- provider: TYPESAFE_PROVIDER,
141
+ api: this.api,
142
+ provider: this.provider,
115
143
  model: response.model,
116
144
  answers: response.answers as JudgmentResult<Q>["answers"],
117
- usage: tokenUsage(response.usage.input_tokens, response.usage.output_tokens),
145
+ usage: tokenUsage(response.usage.input_tokens, response.usage.output_tokens, response.usage.cost),
118
146
  };
119
147
  }
120
148
 
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> {
149
+ async #attempt<T>(path: string, body: string, key: string, signal: AbortSignal | undefined): Promise<T> {
144
150
  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";
151
+ const headers: Record<string, string> = {
152
+ ...this.#headers,
153
+ Authorization: `Bearer ${key}`,
154
+ Accept: "application/json",
155
+ "Content-Type": "application/json",
156
+ };
147
157
  for (let attempt = 0; ; attempt++) {
148
158
  signal?.throwIfAborted();
149
159
  const timeout = AbortSignal.timeout(this.#timeoutMs);
150
160
  let response: Response;
151
161
  try {
152
162
  response = await this.#fetch(url, {
153
- method,
163
+ method: "POST",
154
164
  headers,
155
165
  body,
156
166
  signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
@@ -162,7 +172,7 @@ export class TypeSafeJudge implements Judge {
162
172
  }
163
173
  if (response.ok) return (await response.json()) as T;
164
174
  const text = await response.text();
165
- const error = new TypeSafeApiError(`TypeSafe API error (${response.status}): ${text}`, response.status, {
175
+ const error = new TypeSafeApiError(`${this.label} API error (${response.status}): ${text}`, response.status, {
166
176
  headers: response.headers,
167
177
  });
168
178
  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
 
@@ -0,0 +1,54 @@
1
+ import { isOfficialAnthropicApiUrl } from "@oh-my-pi/pi-catalog/compat/anthropic";
2
+ import type { Model } from "../types";
3
+ import type { AnthropicMessagesClientLike } from "./anthropic-client";
4
+ import { normalizeAnthropicBaseUrl, resolveDirectAnthropicBaseUrl } from "./anthropic-state";
5
+
6
+ function isCompactionCapableModel(model: Model<"anthropic-messages">): boolean {
7
+ return (
8
+ model.compat.supportsServerCompaction === true &&
9
+ model.compat.supportsContextManagement !== false &&
10
+ model.remoteCompaction?.enabled !== false
11
+ );
12
+ }
13
+
14
+ /** Whether the model's effective first-party route is the official Anthropic API. */
15
+ export function resolvesToOfficialAnthropicEndpoint(model: Model<"anthropic-messages">): boolean {
16
+ const baseUrl =
17
+ model.provider === "anthropic" ? resolveDirectAnthropicBaseUrl(model) : normalizeAnthropicBaseUrl(model.baseUrl);
18
+ return isOfficialAnthropicApiUrl(baseUrl);
19
+ }
20
+
21
+ /** Whether the model and effective endpoint support Anthropic native compaction. */
22
+ export function supportsAnthropicCompaction(model: Model<"anthropic-messages">, effectiveBaseUrl?: string): boolean {
23
+ if (!isCompactionCapableModel(model)) return false;
24
+ if (model.remoteCompaction?.enabled === true) return true;
25
+ if (
26
+ model.transport === "pi-native" &&
27
+ model.compat.firstPartyProvider === true &&
28
+ (effectiveBaseUrl === undefined || effectiveBaseUrl === normalizeAnthropicBaseUrl(model.baseUrl))
29
+ ) {
30
+ return true;
31
+ }
32
+ return (
33
+ model.compat.firstPartyProvider === true &&
34
+ (effectiveBaseUrl === undefined
35
+ ? resolvesToOfficialAnthropicEndpoint(model)
36
+ : isOfficialAnthropicApiUrl(effectiveBaseUrl))
37
+ );
38
+ }
39
+
40
+ /** Read a caller-owned client's endpoint for request and compaction routing. */
41
+ export function injectedClientBaseUrl(client: AnthropicMessagesClientLike): string | undefined {
42
+ const candidate = "baseURL" in client ? client.baseURL : undefined;
43
+ return typeof candidate === "string" && candidate.length > 0 ? candidate : undefined;
44
+ }
45
+
46
+ /** Whether a caller-owned Anthropic client targets a compaction-capable endpoint. */
47
+ export function supportsAnthropicCompactionOnClient(
48
+ model: Model<"anthropic-messages">,
49
+ client: AnthropicMessagesClientLike,
50
+ ): boolean {
51
+ const baseUrl = injectedClientBaseUrl(client);
52
+ if (baseUrl !== undefined) return supportsAnthropicCompaction(model, baseUrl);
53
+ return isCompactionCapableModel(model) && model.remoteCompaction?.enabled === true;
54
+ }
@@ -0,0 +1,136 @@
1
+ import * as nodeCrypto from "node:crypto";
2
+ import { getInstallId } from "@oh-my-pi/pi-utils";
3
+ import { claudeToolPrefix } from "./claude-code-fingerprint";
4
+
5
+ const CLAUDE_CLOAKING_USER_ID_REGEX =
6
+ /^user_[0-9a-fA-F]{64}_account_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}_session_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
7
+
8
+ /** Whether a metadata id uses Claude Code's legacy cloaking shape. */
9
+ export function isClaudeCloakingUserId(userId: string): boolean {
10
+ return CLAUDE_CLOAKING_USER_ID_REGEX.test(userId);
11
+ }
12
+
13
+ function isClaudeJsonUserId(userId: string): boolean {
14
+ if (userId.length === 0 || userId[0] !== "{") return false;
15
+ let parsed: unknown;
16
+ try {
17
+ parsed = JSON.parse(userId);
18
+ } catch {
19
+ return false;
20
+ }
21
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed) || !("session_id" in parsed)) return false;
22
+ return typeof parsed.session_id === "string" && parsed.session_id.length > 0;
23
+ }
24
+
25
+ /** Extract a stable session id from a supported Anthropic metadata user id. */
26
+ export function extractClaudeMetadataSessionId(userId: unknown): string | undefined {
27
+ if (typeof userId !== "string") return undefined;
28
+ if (isClaudeCloakingUserId(userId)) {
29
+ return userId.slice(userId.lastIndexOf("_session_") + "_session_".length);
30
+ }
31
+ if (userId.length === 0 || userId[0] !== "{") return undefined;
32
+ let parsed: unknown;
33
+ try {
34
+ parsed = JSON.parse(userId);
35
+ } catch {
36
+ return undefined;
37
+ }
38
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed) || !("session_id" in parsed)) return undefined;
39
+ const sessionId = parsed.session_id;
40
+ return typeof sessionId === "string" && sessionId.length > 0 ? sessionId : undefined;
41
+ }
42
+
43
+ /** Generate a legacy Claude Code cloaking metadata id. */
44
+ export function generateClaudeCloakingUserId(): string {
45
+ const userHash = nodeCrypto.randomBytes(32).toString("hex");
46
+ const accountId = nodeCrypto.randomUUID().toLowerCase();
47
+ const sessionId = nodeCrypto.randomUUID().toLowerCase();
48
+ return `user_${userHash}_account_${accountId}_session_${sessionId}`;
49
+ }
50
+
51
+ const CLAUDE_DEVICE_ID_INSTALL_HASH_DOMAIN = "omp-claude-device-id-v1:";
52
+ const CLAUDE_DEVICE_ID_ACCOUNT_HASH_DOMAIN = "omp-claude-device-id-v2";
53
+
54
+ /** Derive the stable Claude device id for an installation and optional account. */
55
+ export function deriveClaudeDeviceId(installId: string, accountId?: string): string {
56
+ const hash = nodeCrypto.createHash("sha256");
57
+ if (accountId && accountId.length > 0) {
58
+ return hash
59
+ .update(CLAUDE_DEVICE_ID_ACCOUNT_HASH_DOMAIN)
60
+ .update("\0")
61
+ .update(installId)
62
+ .update("\0")
63
+ .update(accountId)
64
+ .digest("hex");
65
+ }
66
+ return hash.update(CLAUDE_DEVICE_ID_INSTALL_HASH_DOMAIN).update(installId).digest("hex");
67
+ }
68
+
69
+ /** Read a non-empty string from provider metadata. */
70
+ export function readAnthropicMetadataString(
71
+ metadata: Record<string, unknown> | undefined,
72
+ key: string,
73
+ ): string | undefined {
74
+ const value = metadata?.[key];
75
+ return typeof value === "string" && value.length > 0 ? value : undefined;
76
+ }
77
+
78
+ /** Resolve the account id aliases accepted in Anthropic request metadata. */
79
+ export function readAnthropicMetadataAccountId(metadata: Record<string, unknown> | undefined): string | undefined {
80
+ return (
81
+ readAnthropicMetadataString(metadata, "account_uuid") ??
82
+ readAnthropicMetadataString(metadata, "accountId") ??
83
+ readAnthropicMetadataString(metadata, "account_id")
84
+ );
85
+ }
86
+
87
+ function deriveClaudeDeviceIdFromInstallId(accountId?: string): string {
88
+ return deriveClaudeDeviceId(getInstallId(), accountId);
89
+ }
90
+
91
+ function generateClaudeJsonUserId(sessionId?: string, accountId?: string): string {
92
+ const userId: Record<string, string> = {
93
+ device_id: deriveClaudeDeviceIdFromInstallId(accountId),
94
+ session_id: sessionId ?? nodeCrypto.randomUUID().toLowerCase(),
95
+ };
96
+ if (accountId && accountId.length > 0) userId.account_uuid = accountId;
97
+ return JSON.stringify(userId);
98
+ }
99
+
100
+ /** Resolve the provider-facing metadata user id for API-key or OAuth requests. */
101
+ export function resolveAnthropicMetadataUserId(
102
+ userId: unknown,
103
+ isOAuthToken: boolean,
104
+ sessionId?: string,
105
+ accountId?: string,
106
+ ): string | undefined {
107
+ if (typeof userId === "string") {
108
+ if (!isOAuthToken || isClaudeCloakingUserId(userId) || isClaudeJsonUserId(userId)) {
109
+ return userId;
110
+ }
111
+ }
112
+
113
+ if (!isOAuthToken) return undefined;
114
+ return generateClaudeJsonUserId(sessionId, accountId);
115
+ }
116
+
117
+ const ANTHROPIC_BUILTIN_TOOL_NAMES: Record<string, true> = {
118
+ web_search: true,
119
+ code_execution: true,
120
+ text_editor: true,
121
+ computer: true,
122
+ };
123
+
124
+ /** Apply Claude Code's wire-only tool prefix. */
125
+ export const applyClaudeToolPrefix = (name: string): string => {
126
+ if (!claudeToolPrefix) return name;
127
+ if (Object.hasOwn(ANTHROPIC_BUILTIN_TOOL_NAMES, name.toLowerCase())) return name;
128
+ return `${claudeToolPrefix}${name}`;
129
+ };
130
+
131
+ /** Remove one Claude Code wire-only tool prefix. */
132
+ export const stripClaudeToolPrefix = (name: string): string => {
133
+ if (!claudeToolPrefix) return name;
134
+ if (!name.toLowerCase().startsWith(claudeToolPrefix.toLowerCase())) return name;
135
+ return name.slice(claudeToolPrefix.length);
136
+ };
@@ -0,0 +1,55 @@
1
+ import { isOfficialAnthropicApiUrl } from "@oh-my-pi/pi-catalog/compat/anthropic";
2
+ import { $env } from "@oh-my-pi/pi-utils";
3
+ import type { Api, Model, ProviderSessionState } from "../types";
4
+ import { isFoundryEnabled } from "../utils/foundry";
5
+
6
+ /** Root key for Anthropic's per-session provider state. */
7
+ export const ANTHROPIC_PROVIDER_SESSION_STATE_KEY = "anthropic-messages";
8
+
9
+ /** Normalize an Anthropic base URL to its origin path without `/v1`. */
10
+ export function normalizeAnthropicBaseUrl(baseUrl?: string): string | undefined {
11
+ const trimmed = baseUrl?.trim();
12
+ if (!trimmed) return undefined;
13
+ const withoutTrailingSlashes = trimmed.replace(/\/+$/, "");
14
+ return withoutTrailingSlashes.endsWith("/v1") ? withoutTrailingSlashes.slice(0, -3) : withoutTrailingSlashes;
15
+ }
16
+
17
+ /** Resolve the endpoint for a first-party Anthropic model without loading its provider implementation. */
18
+ export function resolveDirectAnthropicBaseUrl(model: Model<Api>): string {
19
+ if (isFoundryEnabled()) {
20
+ const foundryBaseUrl = normalizeAnthropicBaseUrl($env.FOUNDRY_BASE_URL);
21
+ if (foundryBaseUrl) return foundryBaseUrl;
22
+ }
23
+ const configured = normalizeAnthropicBaseUrl(model.baseUrl);
24
+ if (configured && !isOfficialAnthropicApiUrl(configured)) return configured;
25
+ return normalizeAnthropicBaseUrl($env.ANTHROPIC_BASE_URL) ?? configured ?? "https://api.anthropic.com";
26
+ }
27
+
28
+ /** Build the endpoint-and-model-scoped Anthropic session-state key. */
29
+ export function anthropicProviderSessionStateKey(baseUrl: string, modelId: string): string {
30
+ return `${ANTHROPIC_PROVIDER_SESSION_STATE_KEY}:${baseUrl}\u0000${modelId}`;
31
+ }
32
+
33
+ /** Re-arm fast mode across materialized Anthropic session-state entries. */
34
+ export function clearAnthropicFastModeFallback(
35
+ providerSessionState: Map<string, ProviderSessionState> | undefined,
36
+ ): void {
37
+ if (!providerSessionState) return;
38
+ const prefix = `${ANTHROPIC_PROVIDER_SESSION_STATE_KEY}:`;
39
+ for (const [key, value] of providerSessionState) {
40
+ if (key !== ANTHROPIC_PROVIDER_SESSION_STATE_KEY && !key.startsWith(prefix)) continue;
41
+ Object.assign(value, { fastModeDisabled: false });
42
+ }
43
+ }
44
+
45
+ /** Inspect the direct model's fast-mode fallback without materializing state. */
46
+ export function isAnthropicFastModeFallbackDisabled(
47
+ providerSessionState: Map<string, ProviderSessionState> | undefined,
48
+ model: Model<Api>,
49
+ ): boolean {
50
+ if (!providerSessionState || model.provider !== "anthropic" || model.api !== "anthropic-messages") return false;
51
+ const baseUrl = resolveDirectAnthropicBaseUrl(model);
52
+ const key = anthropicProviderSessionStateKey(baseUrl, model.id);
53
+ const state = providerSessionState.get(key);
54
+ return state !== undefined && "fastModeDisabled" in state && state.fastModeDisabled === true;
55
+ }