@mupt-ai/dari-gateway 0.2.1 → 0.3.1

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 (71) 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/provider_reported_cost.js +17 -2
  30. package/dist/providers/subscription_limits.d.ts +4 -2
  31. package/dist/providers/subscription_limits.js +19 -3
  32. package/dist/runtime/explicit_model.d.ts +3 -0
  33. package/dist/runtime/explicit_model.js +10 -6
  34. package/dist/runtime/prepare_routed_completion.js +24 -5
  35. package/dist/runtime/strict_provider_access.d.ts +6 -0
  36. package/dist/runtime/strict_provider_access.js +40 -0
  37. package/dist/self_host/resolve.js +11 -15
  38. package/dist/self_host/subscriptions/oauth.d.ts +10 -6
  39. package/dist/self_host/subscriptions/oauth.js +101 -6
  40. package/dist/self_host/subscriptions/store.js +1 -0
  41. package/dist/types.d.ts +4 -1
  42. package/package.json +2 -2
  43. package/src/context/pricing.ts +5 -2
  44. package/src/http/handler.ts +7 -0
  45. package/src/index.ts +9 -1
  46. package/src/ingress/chat_completions.ts +8 -1
  47. package/src/ingress/codex.ts +4 -0
  48. package/src/ingress/models.ts +13 -1
  49. package/src/ingress/muse_code.ts +9 -0
  50. package/src/ingress/muse_models.ts +36 -0
  51. package/src/interaction/types.ts +2 -0
  52. package/src/models/catalog.ts +310 -0
  53. package/src/models/execution_provider.ts +4 -1
  54. package/src/models/provider_defaults.ts +3 -0
  55. package/src/models/runtime_overrides.ts +22 -0
  56. package/src/models/subscriptions.ts +54 -0
  57. package/src/platform/platform.ts +5 -1
  58. package/src/protocol/input/chat_completions.ts +3 -1
  59. package/src/protocol/input/chat_completions_validation.ts +2 -1
  60. package/src/providers/pi.ts +21 -6
  61. package/src/providers/pi_options.ts +17 -1
  62. package/src/providers/provider_call.ts +7 -3
  63. package/src/providers/provider_reported_cost.ts +16 -2
  64. package/src/providers/subscription_limits.ts +21 -4
  65. package/src/runtime/explicit_model.ts +15 -16
  66. package/src/runtime/prepare_routed_completion.ts +22 -3
  67. package/src/runtime/strict_provider_access.ts +42 -0
  68. package/src/self_host/resolve.ts +12 -26
  69. package/src/self_host/subscriptions/oauth.ts +139 -8
  70. package/src/self_host/subscriptions/store.ts +1 -0
  71. package/src/types.ts +20 -3
@@ -23,7 +23,10 @@ export type GatewayPlatform = {
23
23
  * Optional. Supply the credential for one enabled model of this request,
24
24
  * consulted after `resolveRouter` and before its provider keys (for the
25
25
  * self-hosted gateway, its environment keys), so an embedder can serve
26
- * per-tenant keys from its own store. Undefined keeps the resolved key.
26
+ * per-tenant keys from its own store. Undefined keeps the resolved key. A
27
+ * credential with `subscription` serves the model on that plan, even on a
28
+ * strict router; a plan that cannot serve the model for this client (see
29
+ * SubscriptionKind) is ignored.
27
30
  */
28
31
  resolveProviderCredential?(request: ProviderCredentialRequest): Promise<ProviderCredential | undefined>;
29
32
  /** Persist one completed turn's prefix entry and/or refresh a read entry. */
@@ -59,6 +62,7 @@ export type ProviderCredentialRequest = {
59
62
  routerId: string;
60
63
  organizationId: string;
61
64
  apiKey: string;
65
+ clientHarness?: ClientHarness;
62
66
  };
63
67
  export type PrefixStateWrite = {
64
68
  organization_id: string;
@@ -54,7 +54,9 @@ export function internalChatCompletionsToInteraction(request) {
54
54
  const tools = chatTools(request.tools);
55
55
  toolsByWireName(tools);
56
56
  const responseFormat = toInteractionResponseFormat(request.response_format);
57
- const reasoningEffort = request.reasoning_effort;
57
+ // OpenAI spells the non-reasoning effort "none"; Dari spells it "off".
58
+ const requestedEffort = request.reasoning_effort;
59
+ const reasoningEffort = requestedEffort === "none" ? "off" : request.reasoning_effort;
58
60
  return {
59
61
  sourceProtocol: request.source_protocol ?? "openai_chat",
60
62
  model: request.model,
@@ -184,7 +184,8 @@ function validateReasoning(request) {
184
184
  if (raw.reasoning !== undefined) {
185
185
  throw new HttpError(400, "reasoning is not supported; use reasoning_effort.", "invalid_request_error", "reasoning");
186
186
  }
187
- if (request.reasoning_effort !== undefined && !isReasoningEffort(request.reasoning_effort)) {
187
+ const effort = request.reasoning_effort;
188
+ if (effort !== undefined && effort !== "none" && !isReasoningEffort(effort)) {
188
189
  throw new HttpError(400, "reasoning_effort is not supported.", "unsupported_reasoning_effort", "reasoning_effort");
189
190
  }
190
191
  }
@@ -13,7 +13,7 @@ import { REASONING_EFFORTS, isRecord, nativeModelId, providerForModel, } from "@
13
13
  import { HttpError } from "../http/errors.js";
14
14
  import { effectiveContextWindow } from "../models/context_limits.js";
15
15
  import { defaultBaseUrl } from "../models/provider_defaults.js";
16
- import { contextWindowOverride, inputModalitiesOverride, runtimeCompat } from "../models/runtime_overrides.js";
16
+ import { contextWindowOverride, inputModalitiesOverride, providerThinkingLevelOverrides, runtimeCompat, withThinkingLevelOverrides, } from "../models/runtime_overrides.js";
17
17
  import { BEDROCK_API } from "./model_capabilities.js";
18
18
  import { buildContext } from "./pi_context.js";
19
19
  import { providerIdentityFromModel } from "../interaction/provider_continuation_state.js";
@@ -175,15 +175,20 @@ export function buildModel(pi, modelId, pricing, config) {
175
175
  // A null catalog value delegates to Pi's registry when it knows the model.
176
176
  // Custom OpenAI-compatible models are not necessarily registered, so keep
177
177
  // the public effort set permissive and let the provider validate the wire
178
- // value instead of silently treating an unknown model as off-only.
179
- const fallbackThinkingLevels = config !== undefined && configuredThinkingLevels == null && registered === undefined
178
+ // value instead of silently treating an unknown model as off-only. A
179
+ // built-in provider with its own thinking levels (Meta) serves ids newer
180
+ // than the registry the same way, with only its key configured.
181
+ const providerThinking = providerThinkingLevelOverrides(provider);
182
+ const fallbackThinkingLevels = (config !== undefined || providerThinking !== undefined) &&
183
+ configuredThinkingLevels == null &&
184
+ registered === undefined
180
185
  ? REASONING_EFFORTS
181
186
  : undefined;
182
187
  const effectiveThinkingLevels = Array.isArray(configuredThinkingLevels)
183
188
  ? configuredThinkingLevels
184
189
  : fallbackThinkingLevels;
185
190
  const configuredThinking = effectiveThinkingLevels
186
- ? thinkingMetadata(effectiveThinkingLevels, registered)
191
+ ? thinkingMetadata(effectiveThinkingLevels, registered ?? { thinkingLevelMap: providerThinking })
187
192
  : null;
188
193
  const defaultThinkingLevel = config?.default_thinking_level ?? undefined;
189
194
  const api = config?.api ?? registered?.api ?? defaultApi(provider);
@@ -243,7 +248,7 @@ function thinkingMetadata(supportedLevels, registered) {
243
248
  }
244
249
  function resolveModelMetadata(pi, provider, modelId, nativeId) {
245
250
  const registry = registryProvider(provider, modelId);
246
- return pi.getModel?.(registry, nativeId) ?? pi.getModel?.(registry, modelId);
251
+ return withThinkingLevelOverrides(provider, pi.getModel?.(registry, nativeId) ?? pi.getModel?.(registry, modelId));
247
252
  }
248
253
  // Dari's "azure" provider has no Pi registry of its own. One Foundry resource
249
254
  // serves OpenAI deployments through Pi's Azure OpenAI Responses registry and
@@ -307,7 +312,8 @@ function executionPricing(pricing, config) {
307
312
  return pricing ?? modelPricingFromConfig(config);
308
313
  }
309
314
  function defaultApi(provider) {
310
- if (provider === "openai" || provider === "xai")
315
+ // Meta recommends Responses: its Chat Completions drops reasoning between turns.
316
+ if (provider === "openai" || provider === "xai" || provider === "meta")
311
317
  return "openai-responses";
312
318
  if (provider === "anthropic")
313
319
  return "anthropic-messages";
@@ -23,8 +23,11 @@ export function buildOptions(request, auth, selectedModel, model, pi, sessionId,
23
23
  const providerAuth = typeof auth === "string" ? { type: "bearer", apiKey: auth } : auth;
24
24
  const { generation, toolChoice, parallelToolCalls, responseFormat } = request;
25
25
  const headers = {};
26
+ const bearerHeader = sendsBearerToMessages(providerAuth, model);
27
+ if (bearerHeader)
28
+ headers.authorization = `Bearer ${providerAuth.apiKey}`;
26
29
  const options = {
27
- ...authenticationOptions(providerAuth, api),
30
+ ...(bearerHeader ? {} : authenticationOptions(providerAuth, api)),
28
31
  maxRetries: 0,
29
32
  timeoutMs,
30
33
  signal,
@@ -38,6 +41,9 @@ export function buildOptions(request, auth, selectedModel, model, pi, sessionId,
38
41
  if (isClaudeCodeOAuth(providerAuth, api)) {
39
42
  headers["user-agent"] = request.claudeCodeUserAgent ?? CLAUDE_CODE_USER_AGENT;
40
43
  }
44
+ if (model.provider === "meta" && request.museCodeClientId !== undefined) {
45
+ headers["x-client-id"] = request.museCodeClientId;
46
+ }
41
47
  // Pi's Anthropic transport honors compat.supportsTemperature; the OpenAI
42
48
  // transports forward temperature regardless, so drop it here for models
43
49
  // that reject it instead of failing the whole request.
@@ -173,6 +179,12 @@ function authenticationOptions(auth, api) {
173
179
  },
174
180
  };
175
181
  }
182
+ // Anthropic Messages endpoints outside Anthropic that take the key as a bearer
183
+ // token (Claude Code's ANTHROPIC_AUTH_TOKEN) and reject x-api-key.
184
+ const BEARER_MESSAGES_PROVIDERS = new Set(["meta"]);
185
+ function sendsBearerToMessages(auth, model) {
186
+ return auth.type === "bearer" && isAnthropic(model.api) && BEARER_MESSAGES_PROVIDERS.has(model.provider);
187
+ }
176
188
  function isClaudeCodeOAuth(auth, api) {
177
189
  return auth.type === "bearer" && isAnthropic(api) && auth.apiKey.includes("sk-ant-oat");
178
190
  }
@@ -19,8 +19,10 @@ export function prepareProviderExecution(args) {
19
19
  }
20
20
  const provider = executionProviderForModel(args.credentials, model);
21
21
  const supplied = args.credentials.provider_credentials?.[model];
22
- const providerKeySource = supplied ? "user" : keySourceForModel(args.credentials, model);
23
- if (args.credentials.strict === true && providerKeySource === "subscription") {
22
+ const providerKeySource = keySourceForModel(args.credentials, model);
23
+ // A strict router serves a plan only when the platform supplied it for this
24
+ // request, never a pooled or personal one it resolved.
25
+ if (args.credentials.strict === true && providerKeySource === "subscription" && !supplied) {
24
26
  throw new HttpError(400, `Strict routers do not serve '${model}' from a personal subscription.`, "strict_subscription_refused", "model");
25
27
  }
26
28
  const auth = supplied
@@ -47,7 +49,9 @@ export function prepareProviderExecution(args) {
47
49
  : {}),
48
50
  provider,
49
51
  providerKeySource,
50
- ...(providerKeySource === "subscription" && args.credentials.attributed_user_id
52
+ // Only resolved personal subscriptions are held at their usage limit;
53
+ // the platform manages the plans it supplies.
54
+ ...(providerKeySource === "subscription" && !supplied && args.credentials.attributed_user_id
51
55
  ? {
52
56
  subscriptionUserId: args.credentials.attributed_user_id,
53
57
  subscriptionCredentialId: args.credentials.subscription_credential_ids?.[provider],
@@ -2,7 +2,8 @@ import { isRecord } from "@mupt-ai/dari-router/policy-engine";
2
2
  import { gatewayFor } from "../models/gateways.js";
3
3
  import { providerFailureFromCaught } from "../runtime/provider_stream_outcome.js";
4
4
  // Gateways bill per call and say what they billed: OpenRouter as `usage.cost`
5
- // on the final chunk, Vercel AI Gateway as `providerMetadata.gateway.cost`.
5
+ // on the final chunk (plus the provider's charge on a BYOK call), Vercel AI
6
+ // Gateway as `providerMetadata.gateway.cost`.
6
7
  // Pi prices usage from model rates and drops both, so the amount is read off
7
8
  // the raw response bytes and replaces the priced total.
8
9
  export function reportsProviderCost(model) {
@@ -63,7 +64,21 @@ export function reportedCost(body) {
63
64
  const gateway = isRecord(body.providerMetadata) && isRecord(body.providerMetadata.gateway)
64
65
  ? body.providerMetadata.gateway
65
66
  : undefined;
66
- return usd(isRecord(body.usage) ? body.usage.cost : undefined) ?? usd(gateway?.cost);
67
+ return openRouterCost(body.usage) ?? usd(gateway?.cost);
68
+ }
69
+ // On a BYOK call OpenRouter bills the provider's charge to the org's own key:
70
+ // `usage.cost` is only its fee (often 0), and the charge is in
71
+ // `cost_details.upstream_inference_cost`.
72
+ function openRouterCost(usage) {
73
+ if (!isRecord(usage))
74
+ return undefined;
75
+ const cost = usd(usage.cost);
76
+ if (cost === undefined || usage.is_byok !== true)
77
+ return cost;
78
+ const upstream = isRecord(usage.cost_details)
79
+ ? usd(usage.cost_details.upstream_inference_cost)
80
+ : undefined;
81
+ return cost + (upstream ?? 0);
67
82
  }
68
83
  function usd(value) {
69
84
  const amount = typeof value === "string" && value.trim() !== "" ? Number(value) : value;
@@ -1,6 +1,6 @@
1
1
  import type { ProviderKeySource } from "../types.js";
2
2
  import type { CapturedProviderResponse } from "./response_capture.js";
3
- export type SubscriptionOAuthProvider = "anthropic_claude_code" | "openai_codex";
3
+ export type SubscriptionOAuthProvider = "anthropic_claude_code" | "openai_codex" | "meta_muse";
4
4
  export type SubscriptionUsageLimit = {
5
5
  oauthProvider: SubscriptionOAuthProvider;
6
6
  resetsAt: Date;
@@ -12,7 +12,9 @@ export declare function subscriptionOAuthProviderFor(provider: string, keySource
12
12
  /**
13
13
  * Recognize a personal subscription that has run out of quota. Claude Code
14
14
  * answers 429 with the unified rate-limit headers; the ChatGPT backend answers
15
- * 429 with a structured `error.code` and `resets_at`. The Codex WebSocket
15
+ * 429 with a structured `error.code` and `resets_at`. Meta documents no
16
+ * usage-limit signature for Muse Code plans, so any 429 on one holds it until
17
+ * the reset Meta gives, if any. The Codex WebSocket
16
18
  * transport bypasses fetch, so its usage-limit error only survives as message
17
19
  * text and is matched there without a reset time.
18
20
  */
@@ -8,6 +8,7 @@ const FAILURE_MARKER = "subscription_usage_limit";
8
8
  const SUBSCRIPTION_LABEL = {
9
9
  anthropic_claude_code: "Claude Code",
10
10
  openai_codex: "ChatGPT",
11
+ meta_muse: "Muse Code",
11
12
  };
12
13
  export function subscriptionOAuthProviderFor(provider, keySource) {
13
14
  if (keySource !== "subscription")
@@ -16,12 +17,16 @@ export function subscriptionOAuthProviderFor(provider, keySource) {
16
17
  return "anthropic_claude_code";
17
18
  if (provider === "openai" || provider === "openai-codex")
18
19
  return "openai_codex";
20
+ if (provider === "meta")
21
+ return "meta_muse";
19
22
  return null;
20
23
  }
21
24
  /**
22
25
  * Recognize a personal subscription that has run out of quota. Claude Code
23
26
  * answers 429 with the unified rate-limit headers; the ChatGPT backend answers
24
- * 429 with a structured `error.code` and `resets_at`. The Codex WebSocket
27
+ * 429 with a structured `error.code` and `resets_at`. Meta documents no
28
+ * usage-limit signature for Muse Code plans, so any 429 on one holds it until
29
+ * the reset Meta gives, if any. The Codex WebSocket
25
30
  * transport bypasses fetch, so its usage-limit error only survives as message
26
31
  * text and is matched there without a reset time.
27
32
  */
@@ -33,6 +38,8 @@ export function detectSubscriptionUsageLimit(args) {
33
38
  if (oauthProvider === "anthropic_claude_code") {
34
39
  return anthropicUsageLimit(args.captured, now);
35
40
  }
41
+ if (oauthProvider === "meta_muse")
42
+ return museUsageLimit(args.captured, now);
36
43
  return codexUsageLimit(args.captured, providerFailureMessage(args.failure), now);
37
44
  }
38
45
  function anthropicUsageLimit(captured, now) {
@@ -76,6 +83,15 @@ function codexUsageLimit(captured, failureMessage, now) {
76
83
  reason: "ChatGPT usage limit reached.",
77
84
  };
78
85
  }
86
+ function museUsageLimit(captured, now) {
87
+ if (captured === undefined || captured.status !== 429)
88
+ return null;
89
+ return {
90
+ oauthProvider: "meta_muse",
91
+ resetsAt: laterReset(codexErrorBody(captured.bodyText)?.resets_at, captured.headers, now),
92
+ reason: "Muse Code usage limit reached.",
93
+ };
94
+ }
79
95
  function codexErrorBody(bodyText) {
80
96
  try {
81
97
  const parsed = JSON.parse(bodyText);
@@ -137,12 +153,12 @@ export function subscriptionUsageLimitFromFailure(failure) {
137
153
  return null;
138
154
  const oauthProvider = marker.oauth_provider;
139
155
  const resetsAt = typeof marker.resets_at === "string" ? new Date(marker.resets_at) : null;
140
- if ((oauthProvider !== "anthropic_claude_code" && oauthProvider !== "openai_codex")
156
+ if (typeof oauthProvider !== "string" || !(oauthProvider in SUBSCRIPTION_LABEL)
141
157
  || resetsAt === null || Number.isNaN(resetsAt.getTime())) {
142
158
  return null;
143
159
  }
144
160
  return {
145
- oauthProvider,
161
+ oauthProvider: oauthProvider,
146
162
  resetsAt,
147
163
  reason: typeof marker.reason === "string" ? marker.reason : "Subscription usage limit reached.",
148
164
  ...(marker.switched === true ? { switched: true } : {}),
@@ -1,4 +1,7 @@
1
+ import { HttpError } from "../http/errors.js";
1
2
  import type { RoutedDecision } from "../strategies/route_decision.js";
2
3
  import type { PreparedRoutedCompletion } from "./prepare_routed_completion.js";
3
4
  export declare function explicitModelDecision(prepared: PreparedRoutedCompletion): RoutedDecision | null;
4
5
  export declare function strictModelDecision(prepared: PreparedRoutedCompletion): RoutedDecision;
6
+ export declare function modelNotEnabled(modelId: string): HttpError;
7
+ export declare function strictModelRequired(): HttpError;
@@ -8,9 +8,8 @@ export function explicitModelDecision(prepared) {
8
8
  const modelId = explicitModelId(prepared.interaction.model);
9
9
  if (modelId === null)
10
10
  return null;
11
- if (!prepared.credentials.enabled_models.includes(modelId)) {
12
- throw new HttpError(400, `Model '${modelId}' is not enabled on this router.`, "model_not_enabled", "model");
13
- }
11
+ if (!prepared.credentials.enabled_models.includes(modelId))
12
+ throw modelNotEnabled(modelId);
14
13
  const candidates = prepared.routing.candidateResolution.candidates.filter((candidate) => candidate.model === modelId);
15
14
  if (candidates.length === 0) {
16
15
  throw new HttpError(400, `Model '${modelId}' cannot serve this request.`, "model_unavailable", "model");
@@ -40,9 +39,8 @@ function pickCandidate(prepared, modelId, candidates) {
40
39
  refuses everything else instead of substituting. */
41
40
  export function strictModelDecision(prepared) {
42
41
  const modelId = explicitModelId(prepared.interaction.model);
43
- if (modelId === null) {
44
- throw new HttpError(400, "This router is strict: request a pinned model as 'dari/model/<id>'.", "strict_model_required", "model");
45
- }
42
+ if (modelId === null)
43
+ throw strictModelRequired();
46
44
  const { decision } = explicitModelDecision(prepared);
47
45
  const requested = prepared.interaction.reasoning?.effort
48
46
  ?? prepared.credentials.model_configs?.[modelId]?.default_thinking_level;
@@ -51,3 +49,9 @@ export function strictModelDecision(prepared) {
51
49
  }
52
50
  return { decision };
53
51
  }
52
+ export function modelNotEnabled(modelId) {
53
+ return new HttpError(400, `Model '${modelId}' is not enabled on this router.`, "model_not_enabled", "model");
54
+ }
55
+ export function strictModelRequired() {
56
+ return new HttpError(400, "This router is strict: request a pinned model as 'dari/model/<id>'.", "strict_model_required", "model");
57
+ }
@@ -2,11 +2,13 @@ import { interactionConversationPrefix } from "../context/interaction_fingerprin
2
2
  import { buildRoutingContext, derivePrefixLookupHashes, } from "../context/routing_context.js";
3
3
  import { isBackgroundRequest } from "../interaction/types.js";
4
4
  import { executionProviderForModel } from "../models/execution_provider.js";
5
+ import { codexModelConfig, subscriptionServes } from "../models/subscriptions.js";
5
6
  import { gatewayPlatform } from "../platform/platform.js";
6
7
  import { resolveRouter } from "../strategies/routing.js";
7
8
  import { RequestReporter } from "../telemetry/report.js";
8
9
  import { RoutingBillingFinalization } from "../telemetry/billing_finalization.js";
9
10
  import { requestTags } from "./request_tags.js";
11
+ import { strictProviderAccess } from "./strict_provider_access.js";
10
12
  import { scopedHarnessConversationId, scopedHarnessSessionId, } from "./harness_identity/index.js";
11
13
  export async function prepareRoutedCompletion(args) {
12
14
  const tags = requestTags(args.tagsHeader);
@@ -79,7 +81,10 @@ export async function prepareRoutedCompletion(args) {
79
81
  }
80
82
  credentials = conversationCredentials;
81
83
  }
82
- credentials = await withProviderCredentials(credentials, args.apiKey);
84
+ const access = strictProviderAccess(credentials, args.interaction);
85
+ credentials = access.credentials;
86
+ args = { ...args, interaction: access.interaction };
87
+ credentials = await withProviderCredentials(credentials, args.apiKey, args.clientHarness);
83
88
  const billable = true;
84
89
  const routing = await buildRoutingContext({
85
90
  routerId: prefixFingerprintRouterId,
@@ -134,7 +139,7 @@ export async function prepareRoutedCompletion(args) {
134
139
  }
135
140
  // Ask the platform for each enabled model's credential up front, so provider
136
141
  // execution and fallback stay synchronous.
137
- async function withProviderCredentials(credentials, apiKey) {
142
+ async function withProviderCredentials(credentials, apiKey, clientHarness) {
138
143
  const platform = gatewayPlatform();
139
144
  if (platform.resolveProviderCredential === undefined)
140
145
  return credentials;
@@ -146,14 +151,28 @@ async function withProviderCredentials(credentials, apiKey) {
146
151
  routerId: credentials.router_id,
147
152
  organizationId: credentials.organization_id,
148
153
  apiKey,
154
+ ...(clientHarness ? { clientHarness } : {}),
149
155
  }),
150
156
  ]));
151
157
  const provider_credentials = {};
158
+ const model_configs = { ...credentials.model_configs };
152
159
  for (const [model, credential] of resolved) {
153
- if (credential?.apiKey.trim())
154
- provider_credentials[model] = credential;
160
+ if (!credential?.apiKey.trim())
161
+ continue;
162
+ const plan = credential.subscription;
163
+ if (plan !== undefined && !subscriptionServes(plan, model, clientHarness))
164
+ continue;
165
+ provider_credentials[model] = credential;
166
+ if (plan === "openai_codex") {
167
+ // Keep the catalog's limits and thinking levels; only the serving
168
+ // endpoint changes.
169
+ model_configs[model] = {
170
+ ...model_configs[model],
171
+ ...codexModelConfig(model, credentials.model_prices?.[model] ?? null),
172
+ };
173
+ }
155
174
  }
156
- return { ...credentials, provider_credentials };
175
+ return { ...credentials, provider_credentials, model_configs };
157
176
  }
158
177
  // Core's cache-aware pruning audit, extended with the context layer's image
159
178
  // capability exclusions. The exclusions survive on their own when custom
@@ -0,0 +1,6 @@
1
+ import type { InteractionRequest } from "../interaction/types.js";
2
+ import type { ResolvedRouter } from "../types.js";
3
+ export declare function strictProviderAccess(credentials: ResolvedRouter, interaction: InteractionRequest): {
4
+ credentials: ResolvedRouter;
5
+ interaction: InteractionRequest;
6
+ };
@@ -0,0 +1,40 @@
1
+ import { getBuiltinModels, getBuiltinProviders } from "@mupt-ai/pi-ai/providers/all";
2
+ import { HttpError } from "../http/errors.js";
3
+ import { explicitModelId } from "../protocol/input/router_model.js";
4
+ import { modelNotEnabled, strictModelRequired } from "./explicit_model.js";
5
+ // Resolve only the requested catalog model, never turn a provider grant into
6
+ // selector candidates. Unknown IDs fail closed; a grant-only router has no
7
+ // candidates to route among, so it refuses them here with the strict errors.
8
+ export function strictProviderAccess(credentials, interaction) {
9
+ const providers = credentials.strict_providers;
10
+ if (credentials.strict !== true || !providers?.length)
11
+ return { credentials, interaction };
12
+ const requested = explicitModelId(interaction.model);
13
+ const grantOnly = credentials.enabled_models.length === 0;
14
+ if (requested === null) {
15
+ if (grantOnly)
16
+ throw strictModelRequired();
17
+ return { credentials, interaction };
18
+ }
19
+ const matches = new Set();
20
+ for (const provider of providers) {
21
+ if (!getBuiltinProviders().includes(provider)) {
22
+ throw new HttpError(400, `Unknown strict provider '${provider}'.`, "unknown_provider");
23
+ }
24
+ for (const model of getBuiltinModels(provider)) {
25
+ const id = `${provider}/${model.id}`;
26
+ if (requested === id)
27
+ matches.add(id);
28
+ }
29
+ }
30
+ const model = [...matches][0];
31
+ if (model === undefined) {
32
+ if (grantOnly)
33
+ throw modelNotEnabled(requested);
34
+ return { credentials, interaction };
35
+ }
36
+ return {
37
+ credentials: { ...credentials, enabled_models: [...new Set([...credentials.enabled_models, model])] },
38
+ interaction: { ...interaction, model: `dari/model/${model}` },
39
+ };
40
+ }
@@ -1,13 +1,13 @@
1
1
  import { timingSafeEqual } from "node:crypto";
2
2
  import { nativeModelId, providerForModel, } from "@mupt-ai/dari-router/policy-engine";
3
3
  import { HttpError } from "../http/errors.js";
4
+ import { codexModelConfig } from "../models/subscriptions.js";
4
5
  import { registeredModelPrice } from "../providers/pi.js";
5
6
  import { freshAccessToken, } from "./subscriptions/index.js";
6
7
  // A self-hosted gateway is one tenant; these stand in for the hosted
7
8
  // organization and the user a personal subscription belongs to.
8
9
  export const SELF_HOSTED_ORGANIZATION_ID = "self-hosted";
9
10
  export const SELF_HOSTED_USER_ID = "self-hosted";
10
- const CODEX_BACKEND_URL = "https://chatgpt.com/backend-api";
11
11
  /**
12
12
  * Resolve a configured router for one request. Each provider is served by a
13
13
  * connected personal subscription when the router allows it and one is
@@ -129,12 +129,14 @@ export function gatewayKeyIndex(keys, presented) {
129
129
  }
130
130
  return null;
131
131
  }
132
- // Claude Code plans serve only Claude Code itself; ChatGPT plans serve any
133
- // client through the Codex backend.
132
+ // Claude Code plans serve only Claude Code itself and Muse Code plans only
133
+ // Muse Code; ChatGPT plans serve any client through the Codex backend.
134
134
  function subscriptionProviderFor(provider, clientHarness) {
135
135
  if (provider === "anthropic") {
136
136
  return clientHarness === "claude_code" ? "anthropic_claude_code" : null;
137
137
  }
138
+ if (provider === "meta")
139
+ return clientHarness === "muse_code" ? "meta_muse" : null;
138
140
  if (provider === "openai")
139
141
  return "openai_codex";
140
142
  return null;
@@ -146,24 +148,18 @@ function servingProviders(provider, credential) {
146
148
  ? [provider, "openai-codex"]
147
149
  : [provider];
148
150
  }
149
- function codexModelConfig(modelId, price) {
150
- return {
151
- provider: "openai-codex",
152
- native_model_id: nativeModelId(modelId),
153
- api: "openai-codex-responses",
154
- base_url: CODEX_BACKEND_URL,
155
- // A plan bills no tokens; the catalog price only feeds cost estimates.
156
- cost: price ?? { input: 0, output: 0 },
157
- fast_mode: false,
158
- };
159
- }
160
151
  function thinkingLevels(router) {
161
152
  return Object.fromEntries(router.models.flatMap((model) => model.thinking ? [[model.id, model.thinking]] : []));
162
153
  }
154
+ const SUBSCRIPTION_PLAN_NAMES = {
155
+ anthropic_claude_code: "Claude Code",
156
+ openai_codex: "ChatGPT",
157
+ meta_muse: "Muse Code",
158
+ };
163
159
  function unavailableRouterError(routerId, exhausted) {
164
160
  if (exhausted.size > 0) {
165
161
  const plans = [...exhausted]
166
- .map((provider) => provider === "anthropic_claude_code" ? "Claude Code" : "ChatGPT")
162
+ .map((provider) => SUBSCRIPTION_PLAN_NAMES[provider])
167
163
  .join(" and ");
168
164
  return new HttpError(424, `Every connected ${plans} subscription is at its usage limit, and no API key can serve router ${routerId} instead.`, "personal_subscription_unavailable");
169
165
  }
@@ -1,10 +1,11 @@
1
1
  /**
2
2
  * OAuth protocols for personal subscriptions: Claude Code (PKCE, the user
3
- * pastes back the `code#state` Anthropic shows) and ChatGPT/Codex (device
4
- * code, the gateway polls for approval). Every network call goes through the
5
- * injected `fetch`.
3
+ * pastes back the `code#state` Anthropic shows), ChatGPT/Codex (device code,
4
+ * the gateway polls for approval), and Muse Code (Meta's device code, then the
5
+ * subscription's Model API key minted from the sign-in token). Every network
6
+ * call goes through the injected `fetch`.
6
7
  */
7
- export type SubscriptionProvider = "anthropic_claude_code" | "openai_codex";
8
+ export type SubscriptionProvider = "anthropic_claude_code" | "openai_codex" | "meta_muse";
8
9
  export declare const SUBSCRIPTION_PROVIDERS: readonly SubscriptionProvider[];
9
10
  export type OAuthFetch = (url: string, init: RequestInit) => Promise<Response>;
10
11
  export type OAuthOptions = {
@@ -14,7 +15,8 @@ export type OAuthOptions = {
14
15
  /** The provider refused or could not be reached (HTTP 502 at the edge). */
15
16
  export declare class SubscriptionOAuthProviderError extends Error {
16
17
  readonly status?: number | undefined;
17
- constructor(message: string, status?: number | undefined);
18
+ readonly errorCode?: string | undefined;
19
+ constructor(message: string, status?: number | undefined, errorCode?: string | undefined);
18
20
  }
19
21
  /** The user's input or flow state is wrong (HTTP 400 at the edge). */
20
22
  export declare class SubscriptionOAuthInputError extends Error {
@@ -37,10 +39,12 @@ export type OAuthTokens = {
37
39
  export declare const CLAUDE_CODE_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
38
40
  export declare const CODEX_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
39
41
  export declare const CODEX_APPROVAL_PENDING = "OpenAI approval is still pending. Enter the code at OpenAI, finish approval, then try again.";
42
+ export declare const MUSE_CODE_CLIENT_ID = "1031625952748946";
43
+ export declare const META_APPROVAL_PENDING = "Meta approval is still pending. Enter the code at Meta, finish approval, then try again.";
40
44
  export declare function startOAuth(provider: SubscriptionProvider, options: OAuthOptions): Promise<OAuthAuthorization>;
41
45
  /**
42
46
  * Finish a flow. Claude Code needs the pasted authorization response; the
43
- * device-code flow ignores it and asks OpenAI whether the user approved.
47
+ * device-code flows ignore it and ask the provider whether the user approved.
44
48
  */
45
49
  export declare function completeOAuth(provider: SubscriptionProvider, flow: Record<string, string>, authorizationResponse: string, options: OAuthOptions): Promise<OAuthTokens>;
46
50
  export declare function refreshOAuth(credential: {