@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.
- package/README.md +8 -4
- package/dist/context/pricing.js +5 -1
- package/dist/http/handler.js +7 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -0
- package/dist/ingress/chat_completions.js +8 -1
- package/dist/ingress/codex.js +4 -0
- package/dist/ingress/models.js +13 -1
- package/dist/ingress/muse_code.d.ts +2 -0
- package/dist/ingress/muse_code.js +8 -0
- package/dist/ingress/muse_models.d.ts +9 -0
- package/dist/ingress/muse_models.js +32 -0
- package/dist/interaction/types.d.ts +1 -0
- package/dist/models/catalog.d.ts +35 -0
- package/dist/models/catalog.js +232 -0
- package/dist/models/execution_provider.d.ts +2 -1
- package/dist/models/execution_provider.js +5 -1
- package/dist/models/provider_defaults.js +3 -0
- package/dist/models/runtime_overrides.d.ts +3 -0
- package/dist/models/runtime_overrides.js +17 -0
- package/dist/models/subscriptions.d.ts +8 -0
- package/dist/models/subscriptions.js +37 -0
- package/dist/platform/platform.d.ts +5 -1
- package/dist/protocol/input/chat_completions.js +3 -1
- package/dist/protocol/input/chat_completions_validation.js +2 -1
- package/dist/providers/pi.js +12 -6
- package/dist/providers/pi_options.js +13 -1
- package/dist/providers/provider_call.js +7 -3
- package/dist/providers/provider_reported_cost.js +17 -2
- package/dist/providers/subscription_limits.d.ts +4 -2
- package/dist/providers/subscription_limits.js +19 -3
- package/dist/runtime/explicit_model.d.ts +3 -0
- package/dist/runtime/explicit_model.js +10 -6
- package/dist/runtime/prepare_routed_completion.js +24 -5
- package/dist/runtime/strict_provider_access.d.ts +6 -0
- package/dist/runtime/strict_provider_access.js +40 -0
- package/dist/self_host/resolve.js +11 -15
- package/dist/self_host/subscriptions/oauth.d.ts +10 -6
- package/dist/self_host/subscriptions/oauth.js +101 -6
- package/dist/self_host/subscriptions/store.js +1 -0
- package/dist/types.d.ts +4 -1
- package/package.json +2 -2
- package/src/context/pricing.ts +5 -2
- package/src/http/handler.ts +7 -0
- package/src/index.ts +9 -1
- package/src/ingress/chat_completions.ts +8 -1
- package/src/ingress/codex.ts +4 -0
- package/src/ingress/models.ts +13 -1
- package/src/ingress/muse_code.ts +9 -0
- package/src/ingress/muse_models.ts +36 -0
- package/src/interaction/types.ts +2 -0
- package/src/models/catalog.ts +310 -0
- package/src/models/execution_provider.ts +4 -1
- package/src/models/provider_defaults.ts +3 -0
- package/src/models/runtime_overrides.ts +22 -0
- package/src/models/subscriptions.ts +54 -0
- package/src/platform/platform.ts +5 -1
- package/src/protocol/input/chat_completions.ts +3 -1
- package/src/protocol/input/chat_completions_validation.ts +2 -1
- package/src/providers/pi.ts +21 -6
- package/src/providers/pi_options.ts +17 -1
- package/src/providers/provider_call.ts +7 -3
- package/src/providers/provider_reported_cost.ts +16 -2
- package/src/providers/subscription_limits.ts +21 -4
- package/src/runtime/explicit_model.ts +15 -16
- package/src/runtime/prepare_routed_completion.ts +22 -3
- package/src/runtime/strict_provider_access.ts +42 -0
- package/src/self_host/resolve.ts +12 -26
- package/src/self_host/subscriptions/oauth.ts +139 -8
- package/src/self_host/subscriptions/store.ts +1 -0
- package/src/types.ts +20 -3
|
@@ -4,7 +4,7 @@ import { providerFailureMessage } from "../runtime/provider_stream_outcome.js";
|
|
|
4
4
|
import type { ProviderKeySource } from "../types.js";
|
|
5
5
|
import type { CapturedProviderResponse } from "./response_capture.js";
|
|
6
6
|
|
|
7
|
-
export type SubscriptionOAuthProvider = "anthropic_claude_code" | "openai_codex";
|
|
7
|
+
export type SubscriptionOAuthProvider = "anthropic_claude_code" | "openai_codex" | "meta_muse";
|
|
8
8
|
|
|
9
9
|
export type SubscriptionUsageLimit = {
|
|
10
10
|
oauthProvider: SubscriptionOAuthProvider;
|
|
@@ -25,6 +25,7 @@ const FAILURE_MARKER = "subscription_usage_limit";
|
|
|
25
25
|
const SUBSCRIPTION_LABEL: Record<SubscriptionOAuthProvider, string> = {
|
|
26
26
|
anthropic_claude_code: "Claude Code",
|
|
27
27
|
openai_codex: "ChatGPT",
|
|
28
|
+
meta_muse: "Muse Code",
|
|
28
29
|
};
|
|
29
30
|
|
|
30
31
|
export function subscriptionOAuthProviderFor(
|
|
@@ -34,13 +35,16 @@ export function subscriptionOAuthProviderFor(
|
|
|
34
35
|
if (keySource !== "subscription") return null;
|
|
35
36
|
if (provider === "anthropic") return "anthropic_claude_code";
|
|
36
37
|
if (provider === "openai" || provider === "openai-codex") return "openai_codex";
|
|
38
|
+
if (provider === "meta") return "meta_muse";
|
|
37
39
|
return null;
|
|
38
40
|
}
|
|
39
41
|
|
|
40
42
|
/**
|
|
41
43
|
* Recognize a personal subscription that has run out of quota. Claude Code
|
|
42
44
|
* answers 429 with the unified rate-limit headers; the ChatGPT backend answers
|
|
43
|
-
* 429 with a structured `error.code` and `resets_at`.
|
|
45
|
+
* 429 with a structured `error.code` and `resets_at`. Meta documents no
|
|
46
|
+
* usage-limit signature for Muse Code plans, so any 429 on one holds it until
|
|
47
|
+
* the reset Meta gives, if any. The Codex WebSocket
|
|
44
48
|
* transport bypasses fetch, so its usage-limit error only survives as message
|
|
45
49
|
* text and is matched there without a reset time.
|
|
46
50
|
*/
|
|
@@ -57,6 +61,7 @@ export function detectSubscriptionUsageLimit(args: {
|
|
|
57
61
|
if (oauthProvider === "anthropic_claude_code") {
|
|
58
62
|
return anthropicUsageLimit(args.captured, now);
|
|
59
63
|
}
|
|
64
|
+
if (oauthProvider === "meta_muse") return museUsageLimit(args.captured, now);
|
|
60
65
|
return codexUsageLimit(args.captured, providerFailureMessage(args.failure), now);
|
|
61
66
|
}
|
|
62
67
|
|
|
@@ -109,6 +114,18 @@ function codexUsageLimit(
|
|
|
109
114
|
};
|
|
110
115
|
}
|
|
111
116
|
|
|
117
|
+
function museUsageLimit(
|
|
118
|
+
captured: CapturedProviderResponse | undefined,
|
|
119
|
+
now: Date,
|
|
120
|
+
): SubscriptionUsageLimit | null {
|
|
121
|
+
if (captured === undefined || captured.status !== 429) return null;
|
|
122
|
+
return {
|
|
123
|
+
oauthProvider: "meta_muse",
|
|
124
|
+
resetsAt: laterReset(codexErrorBody(captured.bodyText)?.resets_at, captured.headers, now),
|
|
125
|
+
reason: "Muse Code usage limit reached.",
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
112
129
|
function codexErrorBody(bodyText: string): Record<string, unknown> | null {
|
|
113
130
|
try {
|
|
114
131
|
const parsed: unknown = JSON.parse(bodyText);
|
|
@@ -178,13 +195,13 @@ export function subscriptionUsageLimitFromFailure(
|
|
|
178
195
|
const oauthProvider = marker.oauth_provider;
|
|
179
196
|
const resetsAt = typeof marker.resets_at === "string" ? new Date(marker.resets_at) : null;
|
|
180
197
|
if (
|
|
181
|
-
|
|
198
|
+
typeof oauthProvider !== "string" || !(oauthProvider in SUBSCRIPTION_LABEL)
|
|
182
199
|
|| resetsAt === null || Number.isNaN(resetsAt.getTime())
|
|
183
200
|
) {
|
|
184
201
|
return null;
|
|
185
202
|
}
|
|
186
203
|
return {
|
|
187
|
-
oauthProvider,
|
|
204
|
+
oauthProvider: oauthProvider as SubscriptionOAuthProvider,
|
|
188
205
|
resetsAt,
|
|
189
206
|
reason: typeof marker.reason === "string" ? marker.reason : "Subscription usage limit reached.",
|
|
190
207
|
...(marker.switched === true ? { switched: true } : {}),
|
|
@@ -14,14 +14,7 @@ export function explicitModelDecision(
|
|
|
14
14
|
): RoutedDecision | null {
|
|
15
15
|
const modelId = explicitModelId(prepared.interaction.model);
|
|
16
16
|
if (modelId === null) return null;
|
|
17
|
-
if (!prepared.credentials.enabled_models.includes(modelId))
|
|
18
|
-
throw new HttpError(
|
|
19
|
-
400,
|
|
20
|
-
`Model '${modelId}' is not enabled on this router.`,
|
|
21
|
-
"model_not_enabled",
|
|
22
|
-
"model",
|
|
23
|
-
);
|
|
24
|
-
}
|
|
17
|
+
if (!prepared.credentials.enabled_models.includes(modelId)) throw modelNotEnabled(modelId);
|
|
25
18
|
const candidates = prepared.routing.candidateResolution.candidates.filter(
|
|
26
19
|
(candidate) => candidate.model === modelId,
|
|
27
20
|
);
|
|
@@ -63,14 +56,7 @@ function pickCandidate(
|
|
|
63
56
|
refuses everything else instead of substituting. */
|
|
64
57
|
export function strictModelDecision(prepared: PreparedRoutedCompletion): RoutedDecision {
|
|
65
58
|
const modelId = explicitModelId(prepared.interaction.model);
|
|
66
|
-
if (modelId === null)
|
|
67
|
-
throw new HttpError(
|
|
68
|
-
400,
|
|
69
|
-
"This router is strict: request a pinned model as 'dari/model/<id>'.",
|
|
70
|
-
"strict_model_required",
|
|
71
|
-
"model",
|
|
72
|
-
);
|
|
73
|
-
}
|
|
59
|
+
if (modelId === null) throw strictModelRequired();
|
|
74
60
|
const { decision } = explicitModelDecision(prepared)!;
|
|
75
61
|
const requested = prepared.interaction.reasoning?.effort
|
|
76
62
|
?? prepared.credentials.model_configs?.[modelId]?.default_thinking_level;
|
|
@@ -84,3 +70,16 @@ export function strictModelDecision(prepared: PreparedRoutedCompletion): RoutedD
|
|
|
84
70
|
}
|
|
85
71
|
return { decision };
|
|
86
72
|
}
|
|
73
|
+
|
|
74
|
+
export function modelNotEnabled(modelId: string): HttpError {
|
|
75
|
+
return new HttpError(400, `Model '${modelId}' is not enabled on this router.`, "model_not_enabled", "model");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function strictModelRequired(): HttpError {
|
|
79
|
+
return new HttpError(
|
|
80
|
+
400,
|
|
81
|
+
"This router is strict: request a pinned model as 'dari/model/<id>'.",
|
|
82
|
+
"strict_model_required",
|
|
83
|
+
"model",
|
|
84
|
+
);
|
|
85
|
+
}
|
|
@@ -9,12 +9,14 @@ import {
|
|
|
9
9
|
} from "../context/routing_context.js";
|
|
10
10
|
import { isBackgroundRequest, type InteractionRequest } from "../interaction/types.js";
|
|
11
11
|
import { executionProviderForModel } from "../models/execution_provider.js";
|
|
12
|
+
import { codexModelConfig, subscriptionServes } from "../models/subscriptions.js";
|
|
12
13
|
import { gatewayPlatform, type RequestTags } from "../platform/platform.js";
|
|
13
14
|
import { resolveRouter } from "../strategies/routing.js";
|
|
14
15
|
import { RequestReporter, type ReportedCandidatePruning } from "../telemetry/report.js";
|
|
15
16
|
import { RoutingBillingFinalization } from "../telemetry/billing_finalization.js";
|
|
16
17
|
import type { ClientHarness, ProviderCredential, ResolvedRouter } from "../types.js";
|
|
17
18
|
import { requestTags } from "./request_tags.js";
|
|
19
|
+
import { strictProviderAccess } from "./strict_provider_access.js";
|
|
18
20
|
import type { RoutedRuntimeReporter } from "./runtime_reporter.js";
|
|
19
21
|
import type { RoutedProtocol } from "./routed_protocol.js";
|
|
20
22
|
import {
|
|
@@ -125,7 +127,10 @@ export async function prepareRoutedCompletion(args: {
|
|
|
125
127
|
}
|
|
126
128
|
credentials = conversationCredentials;
|
|
127
129
|
}
|
|
128
|
-
|
|
130
|
+
const access = strictProviderAccess(credentials, args.interaction);
|
|
131
|
+
credentials = access.credentials;
|
|
132
|
+
args = { ...args, interaction: access.interaction };
|
|
133
|
+
credentials = await withProviderCredentials(credentials, args.apiKey, args.clientHarness);
|
|
129
134
|
const billable = true;
|
|
130
135
|
const routing = await buildRoutingContext({
|
|
131
136
|
routerId: prefixFingerprintRouterId,
|
|
@@ -188,6 +193,7 @@ export async function prepareRoutedCompletion(args: {
|
|
|
188
193
|
async function withProviderCredentials(
|
|
189
194
|
credentials: ResolvedRouter,
|
|
190
195
|
apiKey: string,
|
|
196
|
+
clientHarness: ClientHarness | undefined,
|
|
191
197
|
): Promise<ResolvedRouter> {
|
|
192
198
|
const platform = gatewayPlatform();
|
|
193
199
|
if (platform.resolveProviderCredential === undefined) return credentials;
|
|
@@ -200,14 +206,27 @@ async function withProviderCredentials(
|
|
|
200
206
|
routerId: credentials.router_id,
|
|
201
207
|
organizationId: credentials.organization_id,
|
|
202
208
|
apiKey,
|
|
209
|
+
...(clientHarness ? { clientHarness } : {}),
|
|
203
210
|
}),
|
|
204
211
|
] as const),
|
|
205
212
|
);
|
|
206
213
|
const provider_credentials: Record<string, ProviderCredential> = {};
|
|
214
|
+
const model_configs = { ...credentials.model_configs };
|
|
207
215
|
for (const [model, credential] of resolved) {
|
|
208
|
-
if (credential?.apiKey.trim())
|
|
216
|
+
if (!credential?.apiKey.trim()) continue;
|
|
217
|
+
const plan = credential.subscription;
|
|
218
|
+
if (plan !== undefined && !subscriptionServes(plan, model, clientHarness)) continue;
|
|
219
|
+
provider_credentials[model] = credential;
|
|
220
|
+
if (plan === "openai_codex") {
|
|
221
|
+
// Keep the catalog's limits and thinking levels; only the serving
|
|
222
|
+
// endpoint changes.
|
|
223
|
+
model_configs[model] = {
|
|
224
|
+
...model_configs[model],
|
|
225
|
+
...codexModelConfig(model, credentials.model_prices?.[model] ?? null),
|
|
226
|
+
};
|
|
227
|
+
}
|
|
209
228
|
}
|
|
210
|
-
return { ...credentials, provider_credentials };
|
|
229
|
+
return { ...credentials, provider_credentials, model_configs };
|
|
211
230
|
}
|
|
212
231
|
|
|
213
232
|
// Core's cache-aware pruning audit, extended with the context layer's image
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { getBuiltinModels, getBuiltinProviders } from "@mupt-ai/pi-ai/providers/all";
|
|
2
|
+
import { HttpError } from "../http/errors.js";
|
|
3
|
+
import type { InteractionRequest } from "../interaction/types.js";
|
|
4
|
+
import { explicitModelId } from "../protocol/input/router_model.js";
|
|
5
|
+
import type { ResolvedRouter } from "../types.js";
|
|
6
|
+
import { modelNotEnabled, strictModelRequired } from "./explicit_model.js";
|
|
7
|
+
|
|
8
|
+
// Resolve only the requested catalog model, never turn a provider grant into
|
|
9
|
+
// selector candidates. Unknown IDs fail closed; a grant-only router has no
|
|
10
|
+
// candidates to route among, so it refuses them here with the strict errors.
|
|
11
|
+
export function strictProviderAccess(
|
|
12
|
+
credentials: ResolvedRouter,
|
|
13
|
+
interaction: InteractionRequest,
|
|
14
|
+
): { credentials: ResolvedRouter; interaction: InteractionRequest } {
|
|
15
|
+
const providers = credentials.strict_providers;
|
|
16
|
+
if (credentials.strict !== true || !providers?.length) return { credentials, interaction };
|
|
17
|
+
const requested = explicitModelId(interaction.model);
|
|
18
|
+
const grantOnly = credentials.enabled_models.length === 0;
|
|
19
|
+
if (requested === null) {
|
|
20
|
+
if (grantOnly) throw strictModelRequired();
|
|
21
|
+
return { credentials, interaction };
|
|
22
|
+
}
|
|
23
|
+
const matches = new Set<string>();
|
|
24
|
+
for (const provider of providers) {
|
|
25
|
+
if (!(getBuiltinProviders() as readonly string[]).includes(provider)) {
|
|
26
|
+
throw new HttpError(400, `Unknown strict provider '${provider}'.`, "unknown_provider");
|
|
27
|
+
}
|
|
28
|
+
for (const model of getBuiltinModels(provider as never)) {
|
|
29
|
+
const id = `${provider}/${model.id}`;
|
|
30
|
+
if (requested === id) matches.add(id);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const model = [...matches][0];
|
|
34
|
+
if (model === undefined) {
|
|
35
|
+
if (grantOnly) throw modelNotEnabled(requested);
|
|
36
|
+
return { credentials, interaction };
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
credentials: { ...credentials, enabled_models: [...new Set([...credentials.enabled_models, model])] },
|
|
40
|
+
interaction: { ...interaction, model: `dari/model/${model}` },
|
|
41
|
+
};
|
|
42
|
+
}
|
package/src/self_host/resolve.ts
CHANGED
|
@@ -4,17 +4,13 @@ import {
|
|
|
4
4
|
nativeModelId,
|
|
5
5
|
providerForModel,
|
|
6
6
|
type ReasoningEffort,
|
|
7
|
-
type RouterModelPrice,
|
|
8
7
|
type RouterPrefixHit,
|
|
9
8
|
} from "@mupt-ai/dari-router/policy-engine";
|
|
10
9
|
import { HttpError } from "../http/errors.js";
|
|
11
10
|
import type { ResolveRouterRequest } from "../platform/platform.js";
|
|
11
|
+
import { codexModelConfig } from "../models/subscriptions.js";
|
|
12
12
|
import { registeredModelPrice } from "../providers/pi.js";
|
|
13
|
-
import type {
|
|
14
|
-
ProviderKeySource,
|
|
15
|
-
ResolvedRouter,
|
|
16
|
-
ResolvedRouterModelConfig,
|
|
17
|
-
} from "../types.js";
|
|
13
|
+
import type { ProviderKeySource, ResolvedRouter } from "../types.js";
|
|
18
14
|
import type { GatewayConfig, RouterConfig } from "./config.js";
|
|
19
15
|
import {
|
|
20
16
|
freshAccessToken,
|
|
@@ -29,8 +25,6 @@ import {
|
|
|
29
25
|
export const SELF_HOSTED_ORGANIZATION_ID = "self-hosted";
|
|
30
26
|
export const SELF_HOSTED_USER_ID = "self-hosted";
|
|
31
27
|
|
|
32
|
-
const CODEX_BACKEND_URL = "https://chatgpt.com/backend-api";
|
|
33
|
-
|
|
34
28
|
export type SelfHostedResolverDeps = {
|
|
35
29
|
config: GatewayConfig;
|
|
36
30
|
gatewayKeys: readonly string[];
|
|
@@ -176,8 +170,8 @@ export function gatewayKeyIndex(keys: readonly string[], presented: string): num
|
|
|
176
170
|
return null;
|
|
177
171
|
}
|
|
178
172
|
|
|
179
|
-
// Claude Code plans serve only Claude Code itself
|
|
180
|
-
// client through the Codex backend.
|
|
173
|
+
// Claude Code plans serve only Claude Code itself and Muse Code plans only
|
|
174
|
+
// Muse Code; ChatGPT plans serve any client through the Codex backend.
|
|
181
175
|
function subscriptionProviderFor(
|
|
182
176
|
provider: string,
|
|
183
177
|
clientHarness: ResolveRouterRequest["clientHarness"],
|
|
@@ -185,6 +179,7 @@ function subscriptionProviderFor(
|
|
|
185
179
|
if (provider === "anthropic") {
|
|
186
180
|
return clientHarness === "claude_code" ? "anthropic_claude_code" : null;
|
|
187
181
|
}
|
|
182
|
+
if (provider === "meta") return clientHarness === "muse_code" ? "meta_muse" : null;
|
|
188
183
|
if (provider === "openai") return "openai_codex";
|
|
189
184
|
return null;
|
|
190
185
|
}
|
|
@@ -197,34 +192,25 @@ function servingProviders(provider: string, credential: ProviderCredential): str
|
|
|
197
192
|
: [provider];
|
|
198
193
|
}
|
|
199
194
|
|
|
200
|
-
function codexModelConfig(
|
|
201
|
-
modelId: string,
|
|
202
|
-
price: RouterModelPrice | null,
|
|
203
|
-
): ResolvedRouterModelConfig {
|
|
204
|
-
return {
|
|
205
|
-
provider: "openai-codex",
|
|
206
|
-
native_model_id: nativeModelId(modelId),
|
|
207
|
-
api: "openai-codex-responses",
|
|
208
|
-
base_url: CODEX_BACKEND_URL,
|
|
209
|
-
// A plan bills no tokens; the catalog price only feeds cost estimates.
|
|
210
|
-
cost: price ?? { input: 0, output: 0 },
|
|
211
|
-
fast_mode: false,
|
|
212
|
-
};
|
|
213
|
-
}
|
|
214
|
-
|
|
215
195
|
function thinkingLevels(router: RouterConfig): Record<string, ReasoningEffort[]> {
|
|
216
196
|
return Object.fromEntries(
|
|
217
197
|
router.models.flatMap((model) => model.thinking ? [[model.id, model.thinking]] : []),
|
|
218
198
|
);
|
|
219
199
|
}
|
|
220
200
|
|
|
201
|
+
const SUBSCRIPTION_PLAN_NAMES: Record<SubscriptionProvider, string> = {
|
|
202
|
+
anthropic_claude_code: "Claude Code",
|
|
203
|
+
openai_codex: "ChatGPT",
|
|
204
|
+
meta_muse: "Muse Code",
|
|
205
|
+
};
|
|
206
|
+
|
|
221
207
|
function unavailableRouterError(
|
|
222
208
|
routerId: string,
|
|
223
209
|
exhausted: ReadonlySet<SubscriptionProvider>,
|
|
224
210
|
): HttpError {
|
|
225
211
|
if (exhausted.size > 0) {
|
|
226
212
|
const plans = [...exhausted]
|
|
227
|
-
.map((provider) => provider
|
|
213
|
+
.map((provider) => SUBSCRIPTION_PLAN_NAMES[provider])
|
|
228
214
|
.join(" and ");
|
|
229
215
|
return new HttpError(
|
|
230
216
|
424,
|
|
@@ -2,16 +2,18 @@ import { createHash, randomBytes } from "node:crypto";
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* OAuth protocols for personal subscriptions: Claude Code (PKCE, the user
|
|
5
|
-
* pastes back the `code#state` Anthropic shows)
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* pastes back the `code#state` Anthropic shows), ChatGPT/Codex (device code,
|
|
6
|
+
* the gateway polls for approval), and Muse Code (Meta's device code, then the
|
|
7
|
+
* subscription's Model API key minted from the sign-in token). Every network
|
|
8
|
+
* call goes through the injected `fetch`.
|
|
8
9
|
*/
|
|
9
10
|
|
|
10
|
-
export type SubscriptionProvider = "anthropic_claude_code" | "openai_codex";
|
|
11
|
+
export type SubscriptionProvider = "anthropic_claude_code" | "openai_codex" | "meta_muse";
|
|
11
12
|
|
|
12
13
|
export const SUBSCRIPTION_PROVIDERS: readonly SubscriptionProvider[] = [
|
|
13
14
|
"anthropic_claude_code",
|
|
14
15
|
"openai_codex",
|
|
16
|
+
"meta_muse",
|
|
15
17
|
];
|
|
16
18
|
|
|
17
19
|
export type OAuthFetch = (url: string, init: RequestInit) => Promise<Response>;
|
|
@@ -20,7 +22,8 @@ export type OAuthOptions = { fetch: OAuthFetch; now?: Date };
|
|
|
20
22
|
|
|
21
23
|
/** The provider refused or could not be reached (HTTP 502 at the edge). */
|
|
22
24
|
export class SubscriptionOAuthProviderError extends Error {
|
|
23
|
-
|
|
25
|
+
// `errorCode` is the provider's OAuth `error` code, such as `authorization_pending`.
|
|
26
|
+
constructor(message: string, readonly status?: number, readonly errorCode?: string) {
|
|
24
27
|
super(message);
|
|
25
28
|
this.name = "SubscriptionOAuthProviderError";
|
|
26
29
|
}
|
|
@@ -43,6 +46,8 @@ export type OAuthAuthorization = {
|
|
|
43
46
|
flow: Record<string, string>;
|
|
44
47
|
};
|
|
45
48
|
|
|
49
|
+
// A Muse Code plan's access token is the Model API key minted for it, and its
|
|
50
|
+
// refresh token the Meta sign-in token the key is minted from.
|
|
46
51
|
export type OAuthTokens = {
|
|
47
52
|
provider: SubscriptionProvider;
|
|
48
53
|
accessToken: string;
|
|
@@ -76,6 +81,23 @@ const CODEX_ACCOUNT_CLAIM = "https://api.openai.com/auth";
|
|
|
76
81
|
export const CODEX_APPROVAL_PENDING =
|
|
77
82
|
"OpenAI approval is still pending. Enter the code at OpenAI, finish approval, then try again.";
|
|
78
83
|
|
|
84
|
+
// Muse Code's own device sign-in (`muse login`), as its public launcher runs it.
|
|
85
|
+
export const MUSE_CODE_CLIENT_ID = "1031625952748946";
|
|
86
|
+
const META_AUTH_BASE_URL = "https://auth.meta.com";
|
|
87
|
+
const META_DEVICE_AUTHORIZATION_URL = `${META_AUTH_BASE_URL}/oidc/device/authorization/`;
|
|
88
|
+
const META_TOKEN_URL = `${META_AUTH_BASE_URL}/oidc/device/token/`;
|
|
89
|
+
// Muse Code mints its subscription's Model API key from the sign-in token here.
|
|
90
|
+
const MUSE_MINT_URL = "https://api.meta.ai/muse-code/key";
|
|
91
|
+
// Meta states no lifetime for a minted key, and Muse Code mints a fresh one at
|
|
92
|
+
// every start, so the key is minted again on this cadence.
|
|
93
|
+
const MUSE_KEY_LIFETIME_MS = 12 * 60 * 60_000;
|
|
94
|
+
// Marks a stored sign-in refresh token, which is exchanged for a fresh sign-in
|
|
95
|
+
// token before each mint. Without it the stored value is the sign-in token.
|
|
96
|
+
const MUSE_SIGN_IN_REFRESH_PREFIX = "refresh:";
|
|
97
|
+
|
|
98
|
+
export const META_APPROVAL_PENDING =
|
|
99
|
+
"Meta approval is still pending. Enter the code at Meta, finish approval, then try again.";
|
|
100
|
+
|
|
79
101
|
export async function startOAuth(
|
|
80
102
|
provider: SubscriptionProvider,
|
|
81
103
|
options: OAuthOptions,
|
|
@@ -103,6 +125,23 @@ export async function startOAuth(
|
|
|
103
125
|
flow: { code_verifier: verifier, state },
|
|
104
126
|
};
|
|
105
127
|
}
|
|
128
|
+
if (provider === "meta_muse") {
|
|
129
|
+
const payload = await postForm(
|
|
130
|
+
options.fetch,
|
|
131
|
+
META_DEVICE_AUTHORIZATION_URL,
|
|
132
|
+
{ client_id: MUSE_CODE_CLIENT_ID },
|
|
133
|
+
"Meta device code request",
|
|
134
|
+
);
|
|
135
|
+
const complete = payload.verification_uri_complete;
|
|
136
|
+
return {
|
|
137
|
+
authorizationUrl: typeof complete === "string" && complete.startsWith("https://")
|
|
138
|
+
? complete
|
|
139
|
+
: requiredString(payload, "verification_uri"),
|
|
140
|
+
expiresAt,
|
|
141
|
+
userCode: requiredString(payload, "user_code"),
|
|
142
|
+
flow: { device_code: requiredString(payload, "device_code") },
|
|
143
|
+
};
|
|
144
|
+
}
|
|
106
145
|
const payload = await postJson(
|
|
107
146
|
options.fetch,
|
|
108
147
|
`${CODEX_DEVICE_AUTH_URL}/usercode`,
|
|
@@ -120,7 +159,7 @@ export async function startOAuth(
|
|
|
120
159
|
|
|
121
160
|
/**
|
|
122
161
|
* Finish a flow. Claude Code needs the pasted authorization response; the
|
|
123
|
-
* device-code
|
|
162
|
+
* device-code flows ignore it and ask the provider whether the user approved.
|
|
124
163
|
*/
|
|
125
164
|
export async function completeOAuth(
|
|
126
165
|
provider: SubscriptionProvider,
|
|
@@ -154,6 +193,35 @@ export async function completeOAuth(
|
|
|
154
193
|
);
|
|
155
194
|
return claudeTokens(token, now, null);
|
|
156
195
|
}
|
|
196
|
+
if (provider === "meta_muse") {
|
|
197
|
+
let token: Record<string, unknown>;
|
|
198
|
+
try {
|
|
199
|
+
token = await postForm(
|
|
200
|
+
options.fetch,
|
|
201
|
+
META_TOKEN_URL,
|
|
202
|
+
{
|
|
203
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
204
|
+
device_code: requiredString(flow, "device_code"),
|
|
205
|
+
client_id: MUSE_CODE_CLIENT_ID,
|
|
206
|
+
},
|
|
207
|
+
"Meta device approval check",
|
|
208
|
+
);
|
|
209
|
+
} catch (error) {
|
|
210
|
+
if (error instanceof SubscriptionOAuthProviderError) {
|
|
211
|
+
if (error.errorCode === "authorization_pending" || error.errorCode === "slow_down") {
|
|
212
|
+
throw new SubscriptionOAuthInputError(META_APPROVAL_PENDING);
|
|
213
|
+
}
|
|
214
|
+
if (error.errorCode === "access_denied") {
|
|
215
|
+
throw new SubscriptionOAuthInputError("The sign-in was declined on Meta. Start again to retry.");
|
|
216
|
+
}
|
|
217
|
+
if (error.errorCode === "expired_token") {
|
|
218
|
+
throw new SubscriptionOAuthInputError("The Meta sign-in code expired. Start again.");
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
throw error;
|
|
222
|
+
}
|
|
223
|
+
return await museTokens(requiredString(token, "access_token"), museRefreshToken(token), options.fetch, now);
|
|
224
|
+
}
|
|
157
225
|
|
|
158
226
|
// The provider, not user-supplied input, supplies the authorization code.
|
|
159
227
|
let approval: Record<string, unknown>;
|
|
@@ -207,6 +275,28 @@ export async function refreshOAuth(
|
|
|
207
275
|
);
|
|
208
276
|
return claudeTokens(token, now, credential.refreshToken);
|
|
209
277
|
}
|
|
278
|
+
if (credential.provider === "meta_muse") {
|
|
279
|
+
let stored = credential.refreshToken;
|
|
280
|
+
let signInToken = stored;
|
|
281
|
+
if (stored.startsWith(MUSE_SIGN_IN_REFRESH_PREFIX)) {
|
|
282
|
+
const token = await postForm(
|
|
283
|
+
options.fetch,
|
|
284
|
+
META_TOKEN_URL,
|
|
285
|
+
{
|
|
286
|
+
grant_type: "refresh_token",
|
|
287
|
+
refresh_token: stored.slice(MUSE_SIGN_IN_REFRESH_PREFIX.length),
|
|
288
|
+
client_id: MUSE_CODE_CLIENT_ID,
|
|
289
|
+
},
|
|
290
|
+
"Meta sign-in refresh",
|
|
291
|
+
);
|
|
292
|
+
signInToken = requiredString(token, "access_token");
|
|
293
|
+
stored = museRefreshToken(token) ?? stored;
|
|
294
|
+
}
|
|
295
|
+
// A lapsed plan fails routing like any other refresh failure.
|
|
296
|
+
return await museTokens(signInToken, stored, options.fetch, now).catch((error: unknown) => {
|
|
297
|
+
throw error instanceof SubscriptionOAuthInputError ? new SubscriptionOAuthProviderError(error.message) : error;
|
|
298
|
+
});
|
|
299
|
+
}
|
|
210
300
|
const token = await postJson(
|
|
211
301
|
options.fetch,
|
|
212
302
|
CODEX_TOKEN_URL,
|
|
@@ -258,6 +348,43 @@ function claudeTokens(
|
|
|
258
348
|
};
|
|
259
349
|
}
|
|
260
350
|
|
|
351
|
+
function museRefreshToken(token: Record<string, unknown>): string | null {
|
|
352
|
+
const value = token.refresh_token;
|
|
353
|
+
return typeof value === "string" && value ? MUSE_SIGN_IN_REFRESH_PREFIX + value : null;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Mints the plan's Model API key from the Meta sign-in token.
|
|
357
|
+
async function museTokens(
|
|
358
|
+
signInToken: string,
|
|
359
|
+
refreshToken: string | null,
|
|
360
|
+
fetch: OAuthFetch,
|
|
361
|
+
now: Date,
|
|
362
|
+
): Promise<OAuthTokens> {
|
|
363
|
+
const minted = await requestJson(
|
|
364
|
+
fetch,
|
|
365
|
+
MUSE_MINT_URL,
|
|
366
|
+
"{}",
|
|
367
|
+
"application/json",
|
|
368
|
+
"Muse Code subscription key mint",
|
|
369
|
+
{ Authorization: `Bearer ${signInToken}`, "x-api-version": "1.0.0" },
|
|
370
|
+
);
|
|
371
|
+
// A key minted without an active plan bills Model API pay-as-you-go.
|
|
372
|
+
if (minted.is_subs_active !== true) {
|
|
373
|
+
throw new SubscriptionOAuthInputError(
|
|
374
|
+
"This Meta account has no active Muse Code subscription. Subscribe at https://accountscenter.meta.com/muse_code/, then connect again.",
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
const email = minted.user_email;
|
|
378
|
+
return {
|
|
379
|
+
provider: "meta_muse",
|
|
380
|
+
accessToken: requiredString(minted, "api_key"),
|
|
381
|
+
refreshToken: refreshToken ?? signInToken,
|
|
382
|
+
expiresAt: new Date(now.getTime() + MUSE_KEY_LIFETIME_MS),
|
|
383
|
+
accountId: null,
|
|
384
|
+
accountEmail: typeof email === "string" && email ? email : null,
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
|
|
261
388
|
function codexTokens(
|
|
262
389
|
payload: Record<string, unknown>,
|
|
263
390
|
now: Date,
|
|
@@ -353,12 +480,13 @@ async function requestJson(
|
|
|
353
480
|
body: string,
|
|
354
481
|
contentType: string,
|
|
355
482
|
operation: string,
|
|
483
|
+
headers: Record<string, string> = {},
|
|
356
484
|
): Promise<Record<string, unknown>> {
|
|
357
485
|
let response: Response;
|
|
358
486
|
try {
|
|
359
487
|
response = await fetch(url, {
|
|
360
488
|
method: "POST",
|
|
361
|
-
headers: { Accept: "application/json", "Content-Type": contentType, "User-Agent": "Dari/1.0" },
|
|
489
|
+
headers: { Accept: "application/json", "Content-Type": contentType, "User-Agent": "Dari/1.0", ...headers },
|
|
362
490
|
body,
|
|
363
491
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
364
492
|
});
|
|
@@ -366,10 +494,13 @@ async function requestJson(
|
|
|
366
494
|
throw new SubscriptionOAuthProviderError(`${operation} could not reach the provider.`);
|
|
367
495
|
}
|
|
368
496
|
if (!response.ok) {
|
|
369
|
-
|
|
497
|
+
// Only the RFC 6749 `error` code is kept; the rest may carry provider detail.
|
|
498
|
+
const failure: unknown = await response.json().catch(() => undefined);
|
|
499
|
+
const errorCode = isRecord(failure) && typeof failure.error === "string" ? failure.error : undefined;
|
|
370
500
|
throw new SubscriptionOAuthProviderError(
|
|
371
501
|
`${operation} failed with provider status ${response.status}.`,
|
|
372
502
|
response.status,
|
|
503
|
+
errorCode,
|
|
373
504
|
);
|
|
374
505
|
}
|
|
375
506
|
let decoded: unknown;
|
|
@@ -47,6 +47,7 @@ export type PendingOAuthSession = {
|
|
|
47
47
|
const BASE_NAMES: Record<SubscriptionProvider, string> = {
|
|
48
48
|
anthropic_claude_code: "ANTHROPIC_CLAUDE_CODE_OAUTH_TOKEN",
|
|
49
49
|
openai_codex: "OPENAI_CODEX_OAUTH_TOKEN",
|
|
50
|
+
meta_muse: "META_MUSE_OAUTH_TOKEN",
|
|
50
51
|
};
|
|
51
52
|
|
|
52
53
|
type CredentialRow = {
|
package/src/types.ts
CHANGED
|
@@ -42,12 +42,25 @@ export type AwsAssumeRoleProviderAuth = {
|
|
|
42
42
|
// Authentication a provider call can use directly.
|
|
43
43
|
export type ResolvedProviderAuth = BearerProviderAuth | AwsSigV4ProviderAuth;
|
|
44
44
|
|
|
45
|
+
// A personal plan whose access token serves its provider's models in place of
|
|
46
|
+
// an API key:
|
|
47
|
+
// - "openai_codex": a ChatGPT plan; the token is Codex's auth.json
|
|
48
|
+
// `tokens.access_token`, served through the Codex backend.
|
|
49
|
+
// - "anthropic_claude_code": a Claude Code plan; the token is the OAuth token
|
|
50
|
+
// `claude setup-token` prints. It serves Claude Code clients only.
|
|
51
|
+
// - "meta_muse": a Meta (Muse) sign-in; the token is the Model API key the
|
|
52
|
+
// sign-in mints (Pi's Meta OAuth `access`).
|
|
53
|
+
export type SubscriptionKind = "openai_codex" | "anthropic_claude_code" | "meta_muse";
|
|
54
|
+
|
|
45
55
|
// A bearer credential an embedding platform supplies for one request through
|
|
46
56
|
// GatewayPlatform.resolveProviderCredential, optionally with its own endpoint.
|
|
57
|
+
// With `subscription`, `apiKey` is that plan's access token; the platform keeps
|
|
58
|
+
// it fresh.
|
|
47
59
|
export type ProviderCredential = {
|
|
48
60
|
apiKey: string;
|
|
49
61
|
baseUrl?: string;
|
|
50
62
|
headers?: Record<string, string>;
|
|
63
|
+
subscription?: SubscriptionKind;
|
|
51
64
|
};
|
|
52
65
|
|
|
53
66
|
export type ProviderAuth = ResolvedProviderAuth | AwsAssumeRoleProviderAuth;
|
|
@@ -70,7 +83,7 @@ export type PublicEvalCatalogCard = Omit<RouterEval, "scores"> & {
|
|
|
70
83
|
scores: PublicEvalCatalogScore[];
|
|
71
84
|
};
|
|
72
85
|
|
|
73
|
-
export type ClientHarness = "claude_code";
|
|
86
|
+
export type ClientHarness = "claude_code" | "muse_code";
|
|
74
87
|
|
|
75
88
|
export type ResolvedRouterModelConfig = {
|
|
76
89
|
provider: string;
|
|
@@ -111,9 +124,13 @@ export type ResolvedRouter = {
|
|
|
111
124
|
speculative_routing?: boolean;
|
|
112
125
|
primary_retries?: number;
|
|
113
126
|
// Serve only the requested `dari/model/<id>` at the requested thinking
|
|
114
|
-
// level: no selector, lease, fallback, or personal subscription
|
|
115
|
-
//
|
|
127
|
+
// level: no selector, lease, fallback, or personal subscription the
|
|
128
|
+
// platform did not supply through resolveProviderCredential, and an error
|
|
129
|
+
// for anything else, whatever the client sends.
|
|
116
130
|
strict?: boolean;
|
|
131
|
+
// On strict routers only, allow requested catalog models of these providers.
|
|
132
|
+
// Requires dari/model/<provider>/<id>; never enables fallback.
|
|
133
|
+
strict_providers?: string[];
|
|
117
134
|
model_fallback_enabled?: boolean;
|
|
118
135
|
fallback_requires_different_provider?: boolean;
|
|
119
136
|
evals?: RouterEval[];
|