@mupt-ai/dari-gateway 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/README.md +8 -4
  2. package/dist/context/pricing.js +5 -1
  3. package/dist/http/handler.js +7 -0
  4. package/dist/index.d.ts +2 -1
  5. package/dist/index.js +1 -0
  6. package/dist/ingress/chat_completions.js +8 -1
  7. package/dist/ingress/codex.js +4 -0
  8. package/dist/ingress/models.js +13 -1
  9. package/dist/ingress/muse_code.d.ts +2 -0
  10. package/dist/ingress/muse_code.js +8 -0
  11. package/dist/ingress/muse_models.d.ts +9 -0
  12. package/dist/ingress/muse_models.js +32 -0
  13. package/dist/interaction/types.d.ts +1 -0
  14. package/dist/models/catalog.d.ts +35 -0
  15. package/dist/models/catalog.js +232 -0
  16. package/dist/models/execution_provider.d.ts +2 -1
  17. package/dist/models/execution_provider.js +5 -1
  18. package/dist/models/provider_defaults.js +3 -0
  19. package/dist/models/runtime_overrides.d.ts +3 -0
  20. package/dist/models/runtime_overrides.js +17 -0
  21. package/dist/models/subscriptions.d.ts +8 -0
  22. package/dist/models/subscriptions.js +37 -0
  23. package/dist/platform/platform.d.ts +5 -1
  24. package/dist/protocol/input/chat_completions.js +3 -1
  25. package/dist/protocol/input/chat_completions_validation.js +2 -1
  26. package/dist/providers/pi.js +12 -6
  27. package/dist/providers/pi_options.js +13 -1
  28. package/dist/providers/provider_call.js +7 -3
  29. package/dist/providers/subscription_limits.d.ts +4 -2
  30. package/dist/providers/subscription_limits.js +19 -3
  31. package/dist/runtime/explicit_model.d.ts +3 -0
  32. package/dist/runtime/explicit_model.js +10 -6
  33. package/dist/runtime/prepare_routed_completion.js +24 -5
  34. package/dist/runtime/strict_provider_access.d.ts +6 -0
  35. package/dist/runtime/strict_provider_access.js +40 -0
  36. package/dist/self_host/resolve.js +11 -15
  37. package/dist/self_host/subscriptions/oauth.d.ts +10 -6
  38. package/dist/self_host/subscriptions/oauth.js +101 -6
  39. package/dist/self_host/subscriptions/store.js +1 -0
  40. package/dist/types.d.ts +4 -1
  41. package/package.json +2 -2
  42. package/src/context/pricing.ts +5 -2
  43. package/src/http/handler.ts +7 -0
  44. package/src/index.ts +9 -1
  45. package/src/ingress/chat_completions.ts +8 -1
  46. package/src/ingress/codex.ts +4 -0
  47. package/src/ingress/models.ts +13 -1
  48. package/src/ingress/muse_code.ts +9 -0
  49. package/src/ingress/muse_models.ts +36 -0
  50. package/src/interaction/types.ts +2 -0
  51. package/src/models/catalog.ts +310 -0
  52. package/src/models/execution_provider.ts +4 -1
  53. package/src/models/provider_defaults.ts +3 -0
  54. package/src/models/runtime_overrides.ts +22 -0
  55. package/src/models/subscriptions.ts +54 -0
  56. package/src/platform/platform.ts +5 -1
  57. package/src/protocol/input/chat_completions.ts +3 -1
  58. package/src/protocol/input/chat_completions_validation.ts +2 -1
  59. package/src/providers/pi.ts +21 -6
  60. package/src/providers/pi_options.ts +17 -1
  61. package/src/providers/provider_call.ts +7 -3
  62. package/src/providers/subscription_limits.ts +21 -4
  63. package/src/runtime/explicit_model.ts +15 -16
  64. package/src/runtime/prepare_routed_completion.ts +22 -3
  65. package/src/runtime/strict_provider_access.ts +42 -0
  66. package/src/self_host/resolve.ts +12 -26
  67. package/src/self_host/subscriptions/oauth.ts +139 -8
  68. package/src/self_host/subscriptions/store.ts +1 -0
  69. package/src/types.ts +20 -3
@@ -2,13 +2,17 @@ import { createHash, randomBytes } from "node:crypto";
2
2
  export const SUBSCRIPTION_PROVIDERS = [
3
3
  "anthropic_claude_code",
4
4
  "openai_codex",
5
+ "meta_muse",
5
6
  ];
6
7
  /** The provider refused or could not be reached (HTTP 502 at the edge). */
7
8
  export class SubscriptionOAuthProviderError extends Error {
8
9
  status;
9
- constructor(message, status) {
10
+ errorCode;
11
+ // `errorCode` is the provider's OAuth `error` code, such as `authorization_pending`.
12
+ constructor(message, status, errorCode) {
10
13
  super(message);
11
14
  this.status = status;
15
+ this.errorCode = errorCode;
12
16
  this.name = "SubscriptionOAuthProviderError";
13
17
  }
14
18
  }
@@ -36,6 +40,20 @@ const CODEX_TOKEN_URL = `${OPENAI_AUTH_BASE_URL}/oauth/token`;
36
40
  const CODEX_REDIRECT_URI = `${OPENAI_AUTH_BASE_URL}/deviceauth/callback`;
37
41
  const CODEX_ACCOUNT_CLAIM = "https://api.openai.com/auth";
38
42
  export const CODEX_APPROVAL_PENDING = "OpenAI approval is still pending. Enter the code at OpenAI, finish approval, then try again.";
43
+ // Muse Code's own device sign-in (`muse login`), as its public launcher runs it.
44
+ export const MUSE_CODE_CLIENT_ID = "1031625952748946";
45
+ const META_AUTH_BASE_URL = "https://auth.meta.com";
46
+ const META_DEVICE_AUTHORIZATION_URL = `${META_AUTH_BASE_URL}/oidc/device/authorization/`;
47
+ const META_TOKEN_URL = `${META_AUTH_BASE_URL}/oidc/device/token/`;
48
+ // Muse Code mints its subscription's Model API key from the sign-in token here.
49
+ const MUSE_MINT_URL = "https://api.meta.ai/muse-code/key";
50
+ // Meta states no lifetime for a minted key, and Muse Code mints a fresh one at
51
+ // every start, so the key is minted again on this cadence.
52
+ const MUSE_KEY_LIFETIME_MS = 12 * 60 * 60_000;
53
+ // Marks a stored sign-in refresh token, which is exchanged for a fresh sign-in
54
+ // token before each mint. Without it the stored value is the sign-in token.
55
+ const MUSE_SIGN_IN_REFRESH_PREFIX = "refresh:";
56
+ export const META_APPROVAL_PENDING = "Meta approval is still pending. Enter the code at Meta, finish approval, then try again.";
39
57
  export async function startOAuth(provider, options) {
40
58
  const expiresAt = new Date((options.now ?? new Date()).getTime() + FLOW_TIMEOUT_MS);
41
59
  if (provider === "anthropic_claude_code") {
@@ -60,6 +78,18 @@ export async function startOAuth(provider, options) {
60
78
  flow: { code_verifier: verifier, state },
61
79
  };
62
80
  }
81
+ if (provider === "meta_muse") {
82
+ const payload = await postForm(options.fetch, META_DEVICE_AUTHORIZATION_URL, { client_id: MUSE_CODE_CLIENT_ID }, "Meta device code request");
83
+ const complete = payload.verification_uri_complete;
84
+ return {
85
+ authorizationUrl: typeof complete === "string" && complete.startsWith("https://")
86
+ ? complete
87
+ : requiredString(payload, "verification_uri"),
88
+ expiresAt,
89
+ userCode: requiredString(payload, "user_code"),
90
+ flow: { device_code: requiredString(payload, "device_code") },
91
+ };
92
+ }
63
93
  const payload = await postJson(options.fetch, `${CODEX_DEVICE_AUTH_URL}/usercode`, { client_id: CODEX_CLIENT_ID }, "OpenAI device code request");
64
94
  const userCode = requiredString({ user_code: payload.user_code || payload.usercode }, "user_code");
65
95
  return {
@@ -71,7 +101,7 @@ export async function startOAuth(provider, options) {
71
101
  }
72
102
  /**
73
103
  * Finish a flow. Claude Code needs the pasted authorization response; the
74
- * device-code flow ignores it and asks OpenAI whether the user approved.
104
+ * device-code flows ignore it and ask the provider whether the user approved.
75
105
  */
76
106
  export async function completeOAuth(provider, flow, authorizationResponse, options) {
77
107
  const now = options.now ?? new Date();
@@ -95,6 +125,31 @@ export async function completeOAuth(provider, flow, authorizationResponse, optio
95
125
  }, "Anthropic subscription token exchange");
96
126
  return claudeTokens(token, now, null);
97
127
  }
128
+ if (provider === "meta_muse") {
129
+ let token;
130
+ try {
131
+ token = await postForm(options.fetch, META_TOKEN_URL, {
132
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
133
+ device_code: requiredString(flow, "device_code"),
134
+ client_id: MUSE_CODE_CLIENT_ID,
135
+ }, "Meta device approval check");
136
+ }
137
+ catch (error) {
138
+ if (error instanceof SubscriptionOAuthProviderError) {
139
+ if (error.errorCode === "authorization_pending" || error.errorCode === "slow_down") {
140
+ throw new SubscriptionOAuthInputError(META_APPROVAL_PENDING);
141
+ }
142
+ if (error.errorCode === "access_denied") {
143
+ throw new SubscriptionOAuthInputError("The sign-in was declined on Meta. Start again to retry.");
144
+ }
145
+ if (error.errorCode === "expired_token") {
146
+ throw new SubscriptionOAuthInputError("The Meta sign-in code expired. Start again.");
147
+ }
148
+ }
149
+ throw error;
150
+ }
151
+ return await museTokens(requiredString(token, "access_token"), museRefreshToken(token), options.fetch, now);
152
+ }
98
153
  // The provider, not user-supplied input, supplies the authorization code.
99
154
  let approval;
100
155
  try {
@@ -129,6 +184,23 @@ export async function refreshOAuth(credential, options) {
129
184
  }, "Anthropic subscription token refresh");
130
185
  return claudeTokens(token, now, credential.refreshToken);
131
186
  }
187
+ if (credential.provider === "meta_muse") {
188
+ let stored = credential.refreshToken;
189
+ let signInToken = stored;
190
+ if (stored.startsWith(MUSE_SIGN_IN_REFRESH_PREFIX)) {
191
+ const token = await postForm(options.fetch, META_TOKEN_URL, {
192
+ grant_type: "refresh_token",
193
+ refresh_token: stored.slice(MUSE_SIGN_IN_REFRESH_PREFIX.length),
194
+ client_id: MUSE_CODE_CLIENT_ID,
195
+ }, "Meta sign-in refresh");
196
+ signInToken = requiredString(token, "access_token");
197
+ stored = museRefreshToken(token) ?? stored;
198
+ }
199
+ // A lapsed plan fails routing like any other refresh failure.
200
+ return await museTokens(signInToken, stored, options.fetch, now).catch((error) => {
201
+ throw error instanceof SubscriptionOAuthInputError ? new SubscriptionOAuthProviderError(error.message) : error;
202
+ });
203
+ }
132
204
  const token = await postJson(options.fetch, CODEX_TOKEN_URL, {
133
205
  grant_type: "refresh_token",
134
206
  refresh_token: credential.refreshToken,
@@ -171,6 +243,27 @@ function claudeTokens(payload, now, fallbackRefreshToken) {
171
243
  accountEmail: typeof email === "string" && email ? email : null,
172
244
  };
173
245
  }
246
+ function museRefreshToken(token) {
247
+ const value = token.refresh_token;
248
+ return typeof value === "string" && value ? MUSE_SIGN_IN_REFRESH_PREFIX + value : null;
249
+ }
250
+ // Mints the plan's Model API key from the Meta sign-in token.
251
+ async function museTokens(signInToken, refreshToken, fetch, now) {
252
+ const minted = await requestJson(fetch, MUSE_MINT_URL, "{}", "application/json", "Muse Code subscription key mint", { Authorization: `Bearer ${signInToken}`, "x-api-version": "1.0.0" });
253
+ // A key minted without an active plan bills Model API pay-as-you-go.
254
+ if (minted.is_subs_active !== true) {
255
+ throw new SubscriptionOAuthInputError("This Meta account has no active Muse Code subscription. Subscribe at https://accountscenter.meta.com/muse_code/, then connect again.");
256
+ }
257
+ const email = minted.user_email;
258
+ return {
259
+ provider: "meta_muse",
260
+ accessToken: requiredString(minted, "api_key"),
261
+ refreshToken: refreshToken ?? signInToken,
262
+ expiresAt: new Date(now.getTime() + MUSE_KEY_LIFETIME_MS),
263
+ accountId: null,
264
+ accountEmail: typeof email === "string" && email ? email : null,
265
+ };
266
+ }
174
267
  function codexTokens(payload, now, fallbackRefreshToken, fallbackAccountId) {
175
268
  const accessToken = requiredString(payload, "access_token");
176
269
  const refreshToken = refreshTokenFrom(payload, fallbackRefreshToken);
@@ -242,12 +335,12 @@ function postJson(fetch, url, payload, operation) {
242
335
  function postForm(fetch, url, payload, operation) {
243
336
  return requestJson(fetch, url, new URLSearchParams(payload).toString(), "application/x-www-form-urlencoded", operation);
244
337
  }
245
- async function requestJson(fetch, url, body, contentType, operation) {
338
+ async function requestJson(fetch, url, body, contentType, operation, headers = {}) {
246
339
  let response;
247
340
  try {
248
341
  response = await fetch(url, {
249
342
  method: "POST",
250
- headers: { Accept: "application/json", "Content-Type": contentType, "User-Agent": "Dari/1.0" },
343
+ headers: { Accept: "application/json", "Content-Type": contentType, "User-Agent": "Dari/1.0", ...headers },
251
344
  body,
252
345
  signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
253
346
  });
@@ -256,8 +349,10 @@ async function requestJson(fetch, url, body, contentType, operation) {
256
349
  throw new SubscriptionOAuthProviderError(`${operation} could not reach the provider.`);
257
350
  }
258
351
  if (!response.ok) {
259
- await response.body?.cancel();
260
- throw new SubscriptionOAuthProviderError(`${operation} failed with provider status ${response.status}.`, response.status);
352
+ // Only the RFC 6749 `error` code is kept; the rest may carry provider detail.
353
+ const failure = await response.json().catch(() => undefined);
354
+ const errorCode = isRecord(failure) && typeof failure.error === "string" ? failure.error : undefined;
355
+ throw new SubscriptionOAuthProviderError(`${operation} failed with provider status ${response.status}.`, response.status, errorCode);
261
356
  }
262
357
  let decoded;
263
358
  try {
@@ -2,6 +2,7 @@ import { randomBytes, randomUUID } from "node:crypto";
2
2
  const BASE_NAMES = {
3
3
  anthropic_claude_code: "ANTHROPIC_CLAUDE_CODE_OAUTH_TOKEN",
4
4
  openai_codex: "OPENAI_CODEX_OAUTH_TOKEN",
5
+ meta_muse: "META_MUSE_OAUTH_TOKEN",
5
6
  };
6
7
  export class SubscriptionStore {
7
8
  db;
package/dist/types.d.ts CHANGED
@@ -18,10 +18,12 @@ export type AwsAssumeRoleProviderAuth = {
18
18
  region: string;
19
19
  };
20
20
  export type ResolvedProviderAuth = BearerProviderAuth | AwsSigV4ProviderAuth;
21
+ export type SubscriptionKind = "openai_codex" | "anthropic_claude_code" | "meta_muse";
21
22
  export type ProviderCredential = {
22
23
  apiKey: string;
23
24
  baseUrl?: string;
24
25
  headers?: Record<string, string>;
26
+ subscription?: SubscriptionKind;
25
27
  };
26
28
  export type ProviderAuth = ResolvedProviderAuth | AwsAssumeRoleProviderAuth;
27
29
  export type AwsProviderAuth = AwsSigV4ProviderAuth | AwsAssumeRoleProviderAuth;
@@ -39,7 +41,7 @@ export type PublicEvalCatalogCard = Omit<RouterEval, "scores"> & {
39
41
  updated_at: string | null;
40
42
  scores: PublicEvalCatalogScore[];
41
43
  };
42
- export type ClientHarness = "claude_code";
44
+ export type ClientHarness = "claude_code" | "muse_code";
43
45
  export type ResolvedRouterModelConfig = {
44
46
  provider: string;
45
47
  native_model_id: string;
@@ -74,6 +76,7 @@ export type ResolvedRouter = {
74
76
  speculative_routing?: boolean;
75
77
  primary_retries?: number;
76
78
  strict?: boolean;
79
+ strict_providers?: string[];
77
80
  model_fallback_enabled?: boolean;
78
81
  fallback_requires_different_provider?: boolean;
79
82
  evals?: RouterEval[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mupt-ai/dari-gateway",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Self-hostable LLM gateway for coding agents and APIs: one endpoint for Anthropic Messages, OpenAI Chat Completions, and OpenAI Responses, with subscription pooling, failover, and prompt-cache-aware routing.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -49,7 +49,7 @@
49
49
  },
50
50
  "dependencies": {
51
51
  "@aws-sdk/client-sts": "3.1063.0",
52
- "@mupt-ai/dari-router": "0.2.4",
52
+ "@mupt-ai/dari-router": "0.2.5",
53
53
  "@mupt-ai/pi-ai": "1.4.0-mupt.1"
54
54
  },
55
55
  "devDependencies": {
@@ -1,7 +1,7 @@
1
1
  import type { ModelPricing, PricingLookup } from "@mupt-ai/dari-router/policy-engine";
2
+ import { registeredModelPrice } from "../providers/pi.js";
2
3
  import type { ResolvedRouter } from "../types.js";
3
4
 
4
-
5
5
  export function pricingLookupForRouter(credentials: ResolvedRouter): PricingLookup {
6
6
  return (model: string): ModelPricing | null => modelPricingForRouter(credentials, model);
7
7
  }
@@ -10,7 +10,10 @@ export function modelPricingForRouter(
10
10
  credentials: ResolvedRouter,
11
11
  model: string,
12
12
  ): ModelPricing | null {
13
- const price = credentials.model_prices?.[model];
13
+ // A model the platform neither prices nor configures is served from Pi's
14
+ // catalog (see buildModel), so its catalog price is the one it is billed at.
15
+ const price = credentials.model_prices?.[model]
16
+ ?? (credentials.model_configs?.[model] === undefined ? registeredModelPrice(model) : null);
14
17
  if (!price || typeof price.input !== "number" || typeof price.output !== "number") return null;
15
18
  // Null cached rates mean the provider publishes none: bill cached prompt
16
19
  // buckets at the input rate, never for free.
@@ -3,6 +3,7 @@ import {
3
3
  handleResponsesWebSocketFallback,
4
4
  } from "../ingress/codex.js";
5
5
  import { handleModels } from "../ingress/models.js";
6
+ import { handleMuseCodeModels } from "../ingress/muse_models.js";
6
7
  import { handleChatCompletions } from "../ingress/chat_completions.js";
7
8
  import { handleAnthropicMessages } from "../ingress/anthropic_messages.js";
8
9
  import { bindGatewayPlatform, type GatewayPlatform } from "../platform/platform.js";
@@ -31,6 +32,12 @@ export function createRequestHandler(options: GatewayHandlerOptions) {
31
32
  ) {
32
33
  return Response.json({ status: "ok" });
33
34
  }
35
+ // Muse Code reads its model list from its base URL's origin; `dari muse`
36
+ // forwards it to the router's own path.
37
+ const museModels = url.pathname.match(/^(?:\/([^/]+))?\/muse-code\/models$/);
38
+ if (request.method === "GET" && museModels !== null) {
39
+ return await handleMuseCodeModels(request, museModels[1] ?? DEFAULT_ROUTER_ALIAS);
40
+ }
34
41
  // Claude Code probes this first-party endpoint before its Messages call
35
42
  // when ANTHROPIC_BASE_URL points at a compatible gateway.
36
43
  if (request.method === "HEAD" && url.pathname === "/api/hello") {
package/src/index.ts CHANGED
@@ -16,7 +16,15 @@ export type {
16
16
  ResolveRouterRequest,
17
17
  SubscriptionHold,
18
18
  } from "./platform/platform.js";
19
+ export {
20
+ DEFAULT_CATALOG_PROVIDERS,
21
+ listModelCatalog,
22
+ type CatalogModel,
23
+ type CatalogModelPrice,
24
+ type CatalogPriceTier,
25
+ type ModelCatalogOptions,
26
+ } from "./models/catalog.js";
19
27
  export { createAutoRoutingSelectorBackend } from "./strategies/auto_routing_selector.js";
20
28
  export { createPrioritySelectorBackend } from "./strategies/priority_selector.js";
21
29
  export { createLlmSelectorBackend, type SelectorBackend } from "./strategies/routing.js";
22
- export type { ProviderCredential, ResolvedRouter } from "./types.js";
30
+ export type { ProviderCredential, ResolvedRouter, SubscriptionKind } from "./types.js";
@@ -11,6 +11,7 @@ import {
11
11
  openCodeRequestContext,
12
12
  } from "../runtime/harness_identity/index.js";
13
13
  import { parseJsonRequest } from "./json.js";
14
+ import { museCodeClientId } from "./muse_code.js";
14
15
 
15
16
  export async function handleChatCompletions(
16
17
  request: Request,
@@ -22,15 +23,21 @@ export async function handleChatCompletions(
22
23
  const payload = await parseJsonRequest<ChatCompletionRequest>(request);
23
24
  const interaction = chatCompletionsToInteraction({ ...payload, source_protocol: "openai_chat" });
24
25
  const agentContext = openCodeRequestContext(request.headers);
26
+ const museClientId = museCodeClientId(request.headers);
25
27
  return await handleRoutedCompletion({
26
28
  request,
27
29
  routerId,
28
30
  apiKey,
29
- payload: agentContext === undefined ? interaction : { ...interaction, agentContext },
31
+ payload: {
32
+ ...interaction,
33
+ ...(agentContext === undefined ? {} : { agentContext }),
34
+ ...(museClientId === undefined ? {} : { museCodeClientId: museClientId }),
35
+ },
30
36
  responseBody: chatCompletionResponse,
31
37
  streamBody: chatCompletionsSseStream,
32
38
  errorResponse,
33
39
  selectorBackend,
40
+ clientHarness: museClientId === undefined ? undefined : "muse_code",
34
41
  harnessConversationIdentity: chatCompletionsConversationIdentity(
35
42
  request.headers,
36
43
  payload.prompt_cache_key,
@@ -5,6 +5,7 @@ import { openAIResponsesResponse } from "../protocol/output/openai_responses.js"
5
5
  import { openAIResponsesSseStream } from "../protocol/output/openai_responses_stream.js";
6
6
  import { openAIResponsesToInteraction } from "../protocol/input/openai_responses.js";
7
7
  import { parseJsonRequest } from "./json.js";
8
+ import { museCodeClientId } from "./muse_code.js";
8
9
  import { handleRoutedCompletion } from "../runtime/routed_completion.js";
9
10
  import type { SelectorBackend } from "../strategies/routing.js";
10
11
  import {
@@ -24,10 +25,12 @@ export async function handleResponses(
24
25
  const interaction = openAIResponsesToInteraction(rawPayload);
25
26
  const requestPurpose = codexRequestPurpose(request.headers, rawPayload);
26
27
  const agentContext = codexRequestContext(request.headers, rawPayload);
28
+ const museClientId = museCodeClientId(request.headers);
27
29
  const payload = {
28
30
  ...interaction,
29
31
  ...(requestPurpose === undefined ? {} : { requestPurpose }),
30
32
  ...(agentContext === undefined ? {} : { agentContext }),
33
+ ...(museClientId === undefined ? {} : { museCodeClientId: museClientId }),
31
34
  };
32
35
  const harnessConversationIdentity = codexConversationIdentity(
33
36
  request.headers,
@@ -47,6 +50,7 @@ export async function handleResponses(
47
50
  streamBody: openAIResponsesSseStream,
48
51
  errorResponse,
49
52
  selectorBackend,
53
+ clientHarness: museClientId === undefined ? undefined : "muse_code",
50
54
  harnessConversationIdentity,
51
55
  });
52
56
  } catch (error) {
@@ -2,12 +2,15 @@
2
2
  Codex reads the Codex catalog; Anthropic-SDK clients (Claude Desktop /
3
3
  Cowork gateway probes, Claude Code) read the Anthropic list shape. The
4
4
  handler detects the caller, authenticates in that protocol's auth shape,
5
- and selects the matching catalog adapter. */
5
+ and selects the matching catalog adapter. `?detail=1` instead returns the
6
+ model catalog (models/catalog.ts), optionally narrowed with
7
+ `provider=<id>[,<id>...]`. */
6
8
 
7
9
  import { anthropicToken, bearerToken } from "../http/auth.js";
8
10
  import { errorResponse } from "../http/errors.js";
9
11
  import { captureRequestError } from "../observability/errors.js";
10
12
  import { anthropicErrorResponse } from "./anthropic_messages.js";
13
+ import { listModelCatalog } from "../models/catalog.js";
11
14
  import { modelSupportsImageInput } from "../providers/model_capabilities.js";
12
15
  import { modelMetadataLookup } from "../providers/pi.js";
13
16
  import { resolveRouter } from "../strategies/routing.js";
@@ -26,6 +29,15 @@ export async function handleModels(request: Request, routerId: string): Promise<
26
29
  // Resolution authenticates the key and confirms the router exists even
27
30
  // when the response body (Anthropic shape) does not depend on it.
28
31
  const router = await resolveRouter({ routerId, apiKey });
32
+ const query = new URL(request.url).searchParams;
33
+ if (query.get("detail") === "1") {
34
+ const providers = query.get("provider")?.split(",").map((id) => id.trim()).filter(Boolean);
35
+ const data = await listModelCatalog({
36
+ ...(providers?.length ? { providers } : {}),
37
+ signal: request.signal,
38
+ });
39
+ return Response.json({ object: "list", data }, { headers: { "Cache-Control": "no-store" } });
40
+ }
29
41
  if (anthropicClient) {
30
42
  return Response.json(anthropicModelsResponse(), {
31
43
  headers: {
@@ -0,0 +1,9 @@
1
+ // Muse Code names its surface (TUI, `muse exec`, desktop, `muse serve`) in
2
+ // x-client-id on every model request.
3
+ const MUSE_CODE_CLIENT_IDS = new Set(["tbh:tui", "tbh:exec", "tbh:desktop", "tbh:serve"]);
4
+
5
+ /** Muse Code's x-client-id when the request comes from Muse Code. */
6
+ export function museCodeClientId(headers: Headers): string | undefined {
7
+ const clientId = headers.get("x-client-id")?.trim().toLowerCase();
8
+ return clientId !== undefined && MUSE_CODE_CLIENT_IDS.has(clientId) ? clientId : undefined;
9
+ }
@@ -0,0 +1,36 @@
1
+ import { bearerToken } from "../http/auth.js";
2
+ import { errorResponse } from "../http/errors.js";
3
+ import { resolveRouter } from "../strategies/routing.js";
4
+ import type { ResolvedRouter } from "../types.js";
5
+
6
+ /**
7
+ * Muse Code fetches its model list from its base URL's origin
8
+ * (`/muse-code/models`) before its first request. A list of only the router's
9
+ * models lets Muse Code pick from them and start on the first; `dari/routing`
10
+ * leads it only on the Autorouting plan.
11
+ */
12
+ export function museCodeModelsResponse(
13
+ router: Pick<ResolvedRouter, "enabled_models" | "autorouting_enabled">,
14
+ ): Record<string, unknown> {
15
+ const ids = [
16
+ ...(router.autorouting_enabled === false ? [] : ["dari/routing"]),
17
+ ...router.enabled_models.map((model) => `dari/model/${model}`),
18
+ ];
19
+ return {
20
+ data: ids.map((id, index) => ({
21
+ id,
22
+ display_label: id === "dari/routing" ? "Dari Routing" : id.slice("dari/model/".length),
23
+ is_default: index === 0,
24
+ })),
25
+ };
26
+ }
27
+
28
+ export async function handleMuseCodeModels(request: Request, routerId: string): Promise<Response> {
29
+ try {
30
+ // Only Muse Code fetches this list, so its plans apply even without a Meta key.
31
+ const router = await resolveRouter({ routerId, apiKey: bearerToken(request.headers), clientHarness: "muse_code" });
32
+ return Response.json(museCodeModelsResponse(router), { headers: { "Cache-Control": "no-store" } });
33
+ } catch (error) {
34
+ return errorResponse(error);
35
+ }
36
+ }
@@ -235,6 +235,8 @@ export type InteractionRequest = {
235
235
  agentContext?: AgentRequestContext;
236
236
  // Claude Code's own user agent; Anthropic gates subscription models on it.
237
237
  claudeCodeUserAgent?: string;
238
+ // Muse Code's own x-client-id, which Meta attributes its requests by.
239
+ museCodeClientId?: string;
238
240
  anthropicSafeguards?: AnthropicSafeguards;
239
241
  model?: string;
240
242
  instructions?: string;