@mupt-ai/dari-gateway 0.2.0 → 0.2.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/dist/bun.d.ts +2 -0
- package/dist/bun.js +4 -0
- package/dist/context/eval_catalog.d.ts +3 -0
- package/dist/context/eval_catalog.js +7 -0
- package/dist/context/interaction_fingerprint.d.ts +27 -0
- package/dist/context/interaction_fingerprint.js +519 -0
- package/dist/context/interaction_fingerprint_format.d.ts +42 -0
- package/dist/context/interaction_fingerprint_format.js +113 -0
- package/dist/context/interaction_lease_depth.d.ts +2 -0
- package/dist/context/interaction_lease_depth.js +110 -0
- package/dist/context/lease_history.d.ts +7 -0
- package/dist/context/lease_history.js +158 -0
- package/dist/context/pricing.d.ts +4 -0
- package/dist/context/pricing.js +23 -0
- package/dist/context/provider_prompt_accounting.d.ts +14 -0
- package/dist/context/provider_prompt_accounting.js +187 -0
- package/dist/context/routing_context.d.ts +40 -0
- package/dist/context/routing_context.js +211 -0
- package/dist/http/auth.d.ts +2 -0
- package/dist/http/auth.js +35 -0
- package/dist/http/errors.d.ts +19 -0
- package/dist/http/errors.js +89 -0
- package/dist/http/handler.d.ts +12 -0
- package/dist/http/handler.js +81 -0
- package/dist/http/http2_server.d.ts +6 -0
- package/dist/http/http2_server.js +103 -0
- package/dist/http/serve.d.ts +20 -0
- package/dist/http/serve.js +78 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +8 -0
- package/dist/ingress/anthropic_messages.d.ts +3 -0
- package/dist/ingress/anthropic_messages.js +122 -0
- package/dist/ingress/anthropic_models.d.ts +1 -0
- package/dist/ingress/anthropic_models.js +23 -0
- package/dist/ingress/chat_completions.d.ts +2 -0
- package/dist/ingress/chat_completions.js +37 -0
- package/dist/ingress/claude_code/request_context.d.ts +2 -0
- package/dist/ingress/claude_code/request_context.js +31 -0
- package/dist/ingress/codex.d.ts +3 -0
- package/dist/ingress/codex.js +65 -0
- package/dist/ingress/codex_models.d.ts +2 -0
- package/dist/ingress/codex_models.js +89 -0
- package/dist/ingress/json.d.ts +1 -0
- package/dist/ingress/json.js +95 -0
- package/dist/ingress/models.d.ts +1 -0
- package/dist/ingress/models.js +53 -0
- package/dist/interaction/capabilities.d.ts +3 -0
- package/dist/interaction/capabilities.js +24 -0
- package/dist/interaction/image.d.ts +4 -0
- package/dist/interaction/image.js +13 -0
- package/dist/interaction/portable_reasoning_state.d.ts +4 -0
- package/dist/interaction/portable_reasoning_state.js +18 -0
- package/dist/interaction/provider_continuation_state.d.ts +6 -0
- package/dist/interaction/provider_continuation_state.js +27 -0
- package/dist/interaction/tool_identity.d.ts +10 -0
- package/dist/interaction/tool_identity.js +41 -0
- package/dist/interaction/types.d.ts +203 -0
- package/dist/interaction/types.js +5 -0
- package/dist/models/context_limits.d.ts +2 -0
- package/dist/models/context_limits.js +7 -0
- package/dist/models/execution_provider.d.ts +8 -0
- package/dist/models/execution_provider.js +13 -0
- package/dist/models/gateways.d.ts +11 -0
- package/dist/models/gateways.js +45 -0
- package/dist/models/ids.d.ts +1 -0
- package/dist/models/ids.js +15 -0
- package/dist/models/provider_defaults.d.ts +4 -0
- package/dist/models/provider_defaults.js +41 -0
- package/dist/models/runtime_overrides.d.ts +4 -0
- package/dist/models/runtime_overrides.js +209 -0
- package/dist/observability/errors.d.ts +8 -0
- package/dist/observability/errors.js +11 -0
- package/dist/platform/platform.d.ts +152 -0
- package/dist/platform/platform.js +11 -0
- package/dist/platform/routing_contract.d.ts +2 -0
- package/dist/platform/routing_contract.js +5 -0
- package/dist/protocol/input/anthropic_messages.d.ts +2 -0
- package/dist/protocol/input/anthropic_messages.js +494 -0
- package/dist/protocol/input/chat_completions.d.ts +4 -0
- package/dist/protocol/input/chat_completions.js +212 -0
- package/dist/protocol/input/chat_completions_validation.d.ts +2 -0
- package/dist/protocol/input/chat_completions_validation.js +257 -0
- package/dist/protocol/input/openai_responses.d.ts +2 -0
- package/dist/protocol/input/openai_responses.js +666 -0
- package/dist/protocol/input/router_model.d.ts +7 -0
- package/dist/protocol/input/router_model.js +43 -0
- package/dist/protocol/output/anthropic_messages.d.ts +10 -0
- package/dist/protocol/output/anthropic_messages.js +15 -0
- package/dist/protocol/output/anthropic_messages_output.d.ts +10 -0
- package/dist/protocol/output/anthropic_messages_output.js +124 -0
- package/dist/protocol/output/anthropic_messages_stream.d.ts +16 -0
- package/dist/protocol/output/anthropic_messages_stream.js +267 -0
- package/dist/protocol/output/chat_completions.d.ts +29 -0
- package/dist/protocol/output/chat_completions.js +406 -0
- package/dist/protocol/output/chat_reasoning_detail.d.ts +2 -0
- package/dist/protocol/output/chat_reasoning_detail.js +13 -0
- package/dist/protocol/output/delivered_stream_snapshot.d.ts +10 -0
- package/dist/protocol/output/delivered_stream_snapshot.js +31 -0
- package/dist/protocol/output/openai_responses.d.ts +10 -0
- package/dist/protocol/output/openai_responses.js +14 -0
- package/dist/protocol/output/openai_responses_output.d.ts +14 -0
- package/dist/protocol/output/openai_responses_output.js +125 -0
- package/dist/protocol/output/openai_responses_stream.d.ts +18 -0
- package/dist/protocol/output/openai_responses_stream.js +546 -0
- package/dist/protocol/output/sse_stream.d.ts +31 -0
- package/dist/protocol/output/sse_stream.js +67 -0
- package/dist/providers/anthropic_safeguards.d.ts +11 -0
- package/dist/providers/anthropic_safeguards.js +81 -0
- package/dist/providers/aws_assume_role.d.ts +17 -0
- package/dist/providers/aws_assume_role.js +107 -0
- package/dist/providers/cancel_estimate.d.ts +43 -0
- package/dist/providers/cancel_estimate.js +89 -0
- package/dist/providers/execution.d.ts +72 -0
- package/dist/providers/execution.js +491 -0
- package/dist/providers/model_capabilities.d.ts +11 -0
- package/dist/providers/model_capabilities.js +29 -0
- package/dist/providers/pi.d.ts +65 -0
- package/dist/providers/pi.js +319 -0
- package/dist/providers/pi_context.d.ts +7 -0
- package/dist/providers/pi_context.js +405 -0
- package/dist/providers/pi_options.d.ts +11 -0
- package/dist/providers/pi_options.js +398 -0
- package/dist/providers/pi_output.d.ts +2 -0
- package/dist/providers/pi_output.js +453 -0
- package/dist/providers/provider_attempt_runner.d.ts +55 -0
- package/dist/providers/provider_attempt_runner.js +90 -0
- package/dist/providers/provider_call.d.ts +77 -0
- package/dist/providers/provider_call.js +292 -0
- package/dist/providers/provider_reported_cost.d.ts +10 -0
- package/dist/providers/provider_reported_cost.js +123 -0
- package/dist/providers/reasoning.d.ts +12 -0
- package/dist/providers/reasoning.js +30 -0
- package/dist/providers/response_capture.d.ts +23 -0
- package/dist/providers/response_capture.js +47 -0
- package/dist/providers/stream_finalization.d.ts +46 -0
- package/dist/providers/stream_finalization.js +165 -0
- package/dist/providers/subscription_limits.d.ts +31 -0
- package/dist/providers/subscription_limits.js +163 -0
- package/dist/providers/subscription_usage.d.ts +10 -0
- package/dist/providers/subscription_usage.js +79 -0
- package/dist/runtime/claude_code/auxiliary_routing.d.ts +3 -0
- package/dist/runtime/claude_code/auxiliary_routing.js +60 -0
- package/dist/runtime/customer_disconnect.d.ts +20 -0
- package/dist/runtime/customer_disconnect.js +49 -0
- package/dist/runtime/explicit_model.d.ts +4 -0
- package/dist/runtime/explicit_model.js +53 -0
- package/dist/runtime/harness_identity/conversation.d.ts +33 -0
- package/dist/runtime/harness_identity/conversation.js +39 -0
- package/dist/runtime/harness_identity/extract.d.ts +8 -0
- package/dist/runtime/harness_identity/extract.js +214 -0
- package/dist/runtime/harness_identity/index.d.ts +2 -0
- package/dist/runtime/harness_identity/index.js +2 -0
- package/dist/runtime/prepare_routed_completion.d.ts +33 -0
- package/dist/runtime/prepare_routed_completion.js +167 -0
- package/dist/runtime/provider_stream_lifecycle.d.ts +31 -0
- package/dist/runtime/provider_stream_lifecycle.js +88 -0
- package/dist/runtime/provider_stream_outcome.d.ts +23 -0
- package/dist/runtime/provider_stream_outcome.js +55 -0
- package/dist/runtime/request_tags.d.ts +7 -0
- package/dist/runtime/request_tags.js +33 -0
- package/dist/runtime/routed_completion.d.ts +17 -0
- package/dist/runtime/routed_completion.js +80 -0
- package/dist/runtime/routed_protocol.d.ts +30 -0
- package/dist/runtime/routed_protocol.js +1 -0
- package/dist/runtime/routed_runtime.d.ts +40 -0
- package/dist/runtime/routed_runtime.js +535 -0
- package/dist/runtime/routing_lease.d.ts +26 -0
- package/dist/runtime/routing_lease.js +210 -0
- package/dist/runtime/runtime_reporter.d.ts +71 -0
- package/dist/runtime/runtime_reporter.js +35 -0
- package/dist/self_host/config.d.ts +46 -0
- package/dist/self_host/config.js +105 -0
- package/dist/self_host/main.d.ts +16 -0
- package/dist/self_host/main.js +95 -0
- package/dist/self_host/platform.d.ts +13 -0
- package/dist/self_host/platform.js +50 -0
- package/dist/self_host/prefix_store.d.ts +46 -0
- package/dist/self_host/prefix_store.js +261 -0
- package/dist/self_host/resolve.d.ts +28 -0
- package/dist/self_host/resolve.js +171 -0
- package/dist/self_host/subscriptions/access.d.ts +10 -0
- package/dist/self_host/subscriptions/access.js +37 -0
- package/dist/self_host/subscriptions/http.d.ts +15 -0
- package/dist/self_host/subscriptions/http.js +187 -0
- package/dist/self_host/subscriptions/index.d.ts +24 -0
- package/dist/self_host/subscriptions/index.js +24 -0
- package/dist/self_host/subscriptions/oauth.d.ts +55 -0
- package/dist/self_host/subscriptions/oauth.js +281 -0
- package/dist/self_host/subscriptions/store.d.ts +78 -0
- package/dist/self_host/subscriptions/store.js +235 -0
- package/dist/strategies/auto_routing_selector.d.ts +12 -0
- package/dist/strategies/auto_routing_selector.js +61 -0
- package/dist/strategies/eval_relevance.d.ts +17 -0
- package/dist/strategies/eval_relevance.js +32 -0
- package/dist/strategies/fallback_decision.d.ts +8 -0
- package/dist/strategies/fallback_decision.js +17 -0
- package/dist/strategies/priority_selector.d.ts +7 -0
- package/dist/strategies/priority_selector.js +37 -0
- package/dist/strategies/route_decision.d.ts +22 -0
- package/dist/strategies/route_decision.js +65 -0
- package/dist/strategies/routing.d.ts +74 -0
- package/dist/strategies/routing.js +280 -0
- package/dist/strategies/selector_projection.d.ts +15 -0
- package/dist/strategies/selector_projection.js +161 -0
- package/dist/telemetry/background.d.ts +7 -0
- package/dist/telemetry/background.js +33 -0
- package/dist/telemetry/benchmark_replay.d.ts +37 -0
- package/dist/telemetry/benchmark_replay.js +108 -0
- package/dist/telemetry/billing_finalization.d.ts +64 -0
- package/dist/telemetry/billing_finalization.js +96 -0
- package/dist/telemetry/ingest_values.d.ts +4 -0
- package/dist/telemetry/ingest_values.js +12 -0
- package/dist/telemetry/model_cost.d.ts +3 -0
- package/dist/telemetry/model_cost.js +15 -0
- package/dist/telemetry/prefix_state.d.ts +47 -0
- package/dist/telemetry/prefix_state.js +116 -0
- package/dist/telemetry/report.d.ts +88 -0
- package/dist/telemetry/report.js +621 -0
- package/dist/telemetry/requests/interaction_observability.d.ts +45 -0
- package/dist/telemetry/requests/interaction_observability.js +180 -0
- package/dist/telemetry/requests/retained_interaction_request.d.ts +8 -0
- package/dist/telemetry/requests/retained_interaction_request.js +10 -0
- package/dist/telemetry/scrub.d.ts +2 -0
- package/dist/telemetry/scrub.js +31 -0
- package/dist/telemetry/skills/claude/catalog.d.ts +6 -0
- package/dist/telemetry/skills/claude/catalog.js +99 -0
- package/dist/telemetry/skills/claude/file_reads.d.ts +4 -0
- package/dist/telemetry/skills/claude/file_reads.js +110 -0
- package/dist/telemetry/skills/claude/observations.d.ts +3 -0
- package/dist/telemetry/skills/claude/observations.js +366 -0
- package/dist/telemetry/skills/codex/catalog.d.ts +11 -0
- package/dist/telemetry/skills/codex/catalog.js +138 -0
- package/dist/telemetry/skills/codex/exec_file_reads.d.ts +9 -0
- package/dist/telemetry/skills/codex/exec_file_reads.js +36 -0
- package/dist/telemetry/skills/codex/explicit.d.ts +3 -0
- package/dist/telemetry/skills/codex/explicit.js +90 -0
- package/dist/telemetry/skills/codex/file_reads.d.ts +4 -0
- package/dist/telemetry/skills/codex/file_reads.js +37 -0
- package/dist/telemetry/skills/codex/identity.d.ts +2 -0
- package/dist/telemetry/skills/codex/identity.js +20 -0
- package/dist/telemetry/skills/observations.d.ts +21 -0
- package/dist/telemetry/skills/observations.js +41 -0
- package/dist/telemetry/skills/paths.d.ts +11 -0
- package/dist/telemetry/skills/paths.js +61 -0
- package/dist/telemetry/skills/pi.d.ts +6 -0
- package/dist/telemetry/skills/pi.js +223 -0
- package/dist/telemetry/skills/shell_file_reads.d.ts +7 -0
- package/dist/telemetry/skills/shell_file_reads.js +226 -0
- package/dist/telemetry/skills/shell_words.d.ts +13 -0
- package/dist/telemetry/skills/shell_words.js +189 -0
- package/dist/telemetry/tools/input.d.ts +2 -0
- package/dist/telemetry/tools/input.js +14 -0
- package/dist/telemetry/tools/observations.d.ts +29 -0
- package/dist/telemetry/tools/observations.js +247 -0
- package/dist/telemetry/usage.d.ts +57 -0
- package/dist/telemetry/usage.js +136 -0
- package/dist/types.d.ts +94 -0
- package/dist/types.js +1 -0
- package/package.json +21 -6
- package/src/bun.ts +2 -2
- package/src/context/eval_catalog.ts +1 -1
- package/src/context/interaction_fingerprint.ts +9 -9
- package/src/context/interaction_fingerprint_format.ts +1 -1
- package/src/context/interaction_lease_depth.ts +1 -1
- package/src/context/pricing.ts +1 -1
- package/src/context/provider_prompt_accounting.ts +5 -5
- package/src/context/routing_context.ts +16 -16
- package/src/http/auth.ts +1 -1
- package/src/http/handler.ts +8 -8
- package/src/http/serve.ts +3 -3
- package/src/index.ts +6 -6
- package/src/ingress/anthropic_messages.ts +12 -12
- package/src/ingress/anthropic_models.ts +1 -1
- package/src/ingress/chat_completions.ts +9 -9
- package/src/ingress/claude_code/request_context.ts +2 -2
- package/src/ingress/codex.ts +10 -10
- package/src/ingress/codex_models.ts +4 -4
- package/src/ingress/json.ts +1 -1
- package/src/ingress/models.ts +9 -9
- package/src/interaction/capabilities.ts +1 -1
- package/src/interaction/portable_reasoning_state.ts +1 -1
- package/src/interaction/provider_continuation_state.ts +1 -1
- package/src/interaction/tool_identity.ts +2 -2
- package/src/models/context_limits.ts +1 -1
- package/src/models/execution_provider.ts +1 -1
- package/src/models/ids.ts +1 -1
- package/src/models/provider_defaults.ts +1 -1
- package/src/models/runtime_overrides.ts +1 -1
- package/src/observability/errors.ts +2 -2
- package/src/platform/platform.ts +4 -4
- package/src/protocol/input/anthropic_messages.ts +7 -7
- package/src/protocol/input/chat_completions.ts +5 -5
- package/src/protocol/input/chat_completions_validation.ts +4 -4
- package/src/protocol/input/openai_responses.ts +6 -6
- package/src/protocol/output/anthropic_messages.ts +3 -3
- package/src/protocol/output/anthropic_messages_output.ts +6 -4
- package/src/protocol/output/anthropic_messages_stream.ts +7 -7
- package/src/protocol/output/chat_completions.ts +13 -13
- package/src/protocol/output/chat_reasoning_detail.ts +1 -1
- package/src/protocol/output/openai_responses.ts +3 -3
- package/src/protocol/output/openai_responses_output.ts +4 -2
- package/src/protocol/output/openai_responses_stream.ts +6 -6
- package/src/protocol/output/sse_stream.ts +3 -3
- package/src/providers/anthropic_safeguards.ts +2 -2
- package/src/providers/aws_assume_role.ts +2 -2
- package/src/providers/cancel_estimate.ts +1 -1
- package/src/providers/execution.ts +17 -17
- package/src/providers/pi.ts +14 -14
- package/src/providers/pi_context.ts +6 -6
- package/src/providers/pi_options.ts +10 -10
- package/src/providers/pi_output.ts +5 -5
- package/src/providers/provider_attempt_runner.ts +1 -1
- package/src/providers/provider_call.ts +14 -14
- package/src/providers/provider_reported_cost.ts +3 -3
- package/src/providers/reasoning.ts +2 -2
- package/src/providers/stream_finalization.ts +9 -9
- package/src/providers/subscription_limits.ts +3 -3
- package/src/runtime/claude_code/auxiliary_routing.ts +5 -5
- package/src/runtime/customer_disconnect.ts +1 -1
- package/src/runtime/explicit_model.ts +4 -4
- package/src/runtime/harness_identity/conversation.ts +1 -1
- package/src/runtime/harness_identity/extract.ts +3 -3
- package/src/runtime/harness_identity/index.ts +2 -2
- package/src/runtime/prepare_routed_completion.ts +13 -13
- package/src/runtime/provider_stream_lifecycle.ts +1 -1
- package/src/runtime/provider_stream_outcome.ts +2 -2
- package/src/runtime/request_tags.ts +2 -2
- package/src/runtime/routed_completion.ts +11 -11
- package/src/runtime/routed_protocol.ts +2 -2
- package/src/runtime/routed_runtime.ts +20 -19
- package/src/runtime/routing_lease.ts +7 -7
- package/src/runtime/runtime_reporter.ts +3 -3
- package/src/self_host/main.ts +12 -12
- package/src/self_host/platform.ts +5 -5
- package/src/self_host/prefix_store.ts +2 -2
- package/src/self_host/resolve.ts +6 -6
- package/src/self_host/subscriptions/access.ts +2 -2
- package/src/self_host/subscriptions/http.ts +2 -2
- package/src/self_host/subscriptions/index.ts +4 -4
- package/src/self_host/subscriptions/store.ts +1 -1
- package/src/strategies/auto_routing_selector.ts +2 -2
- package/src/strategies/eval_relevance.ts +3 -3
- package/src/strategies/priority_selector.ts +1 -1
- package/src/strategies/route_decision.ts +7 -7
- package/src/strategies/routing.ts +10 -10
- package/src/strategies/selector_projection.ts +2 -2
- package/src/telemetry/billing_finalization.ts +3 -3
- package/src/telemetry/model_cost.ts +1 -1
- package/src/telemetry/prefix_state.ts +4 -4
- package/src/telemetry/report.ts +21 -21
- package/src/telemetry/requests/interaction_observability.ts +1 -1
- package/src/telemetry/requests/retained_interaction_request.ts +1 -1
- package/src/telemetry/skills/claude/catalog.ts +1 -1
- package/src/telemetry/skills/claude/file_reads.ts +6 -6
- package/src/telemetry/skills/claude/observations.ts +3 -3
- package/src/telemetry/skills/codex/catalog.ts +3 -3
- package/src/telemetry/skills/codex/exec_file_reads.ts +4 -4
- package/src/telemetry/skills/codex/explicit.ts +3 -3
- package/src/telemetry/skills/codex/file_reads.ts +5 -5
- package/src/telemetry/skills/observations.ts +7 -7
- package/src/telemetry/skills/pi.ts +3 -3
- package/src/telemetry/skills/shell_file_reads.ts +2 -2
- package/src/telemetry/skills/shell_words.ts +1 -1
- package/src/telemetry/tools/input.ts +1 -1
- package/src/telemetry/tools/observations.ts +9 -9
- package/src/telemetry/usage.ts +5 -5
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The subscription endpoints the Dari CLI calls, with the same paths and JSON
|
|
3
|
+
* shapes as the hosted API. The gateway is single-tenant: `current` is the
|
|
4
|
+
* only organization, and every caller that passes the gateway's own auth owns
|
|
5
|
+
* every subscription. Errors are `{"detail": "..."}`, which the CLI prints.
|
|
6
|
+
*/
|
|
7
|
+
import { SUBSCRIPTION_PROVIDERS, SubscriptionOAuthInputError, SubscriptionOAuthProviderError, completeOAuth, startOAuth, } from "./oauth.js";
|
|
8
|
+
import { isHeld } from "./store.js";
|
|
9
|
+
const BASE = "/v1/organizations/current/credentials";
|
|
10
|
+
// Subscription names are env-var-style, which keeps other credential routes
|
|
11
|
+
// such as `provider-credentials/...` out of these matches.
|
|
12
|
+
const NAMED = /^\/([A-Za-z_][A-Za-z0-9_]*)(\/label|\/mark-available|\/auto-reset)?$/;
|
|
13
|
+
class HttpError extends Error {
|
|
14
|
+
status;
|
|
15
|
+
constructor(status, message) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.status = status;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/** Handle a subscription route, or return null when the request is not one. */
|
|
21
|
+
export async function handleSubscriptionRequest(request, deps) {
|
|
22
|
+
const url = new URL(request.url);
|
|
23
|
+
if (url.pathname !== BASE && !url.pathname.startsWith(`${BASE}/`))
|
|
24
|
+
return null;
|
|
25
|
+
const path = url.pathname.slice(BASE.length);
|
|
26
|
+
const now = deps.now?.() ?? new Date();
|
|
27
|
+
try {
|
|
28
|
+
if (path === "" && request.method === "GET")
|
|
29
|
+
return json(200, listBody(deps.store, now));
|
|
30
|
+
if (path === "/oauth/sessions" && request.method === "POST") {
|
|
31
|
+
return json(201, await startSession(deps, await readBody(request), now));
|
|
32
|
+
}
|
|
33
|
+
if (path === "/oauth/sessions/complete" && request.method === "POST") {
|
|
34
|
+
return json(200, await completeSession(deps, await readBody(request), now));
|
|
35
|
+
}
|
|
36
|
+
const named = NAMED.exec(path);
|
|
37
|
+
if (named === null)
|
|
38
|
+
return null;
|
|
39
|
+
const name = named[1];
|
|
40
|
+
const action = named[2];
|
|
41
|
+
if (action === undefined && request.method === "DELETE") {
|
|
42
|
+
// The hosted API requires `scope=user` so an old client deleting an
|
|
43
|
+
// organization secret by name never disconnects a subscription.
|
|
44
|
+
if (url.searchParams.get("scope") !== "user")
|
|
45
|
+
throw new HttpError(400, "scope=user is required.");
|
|
46
|
+
const removed = deps.store.disconnect(name);
|
|
47
|
+
return json(200, credentialBody(found(removed, name), false, now));
|
|
48
|
+
}
|
|
49
|
+
if (action === "/label" && request.method === "PUT") {
|
|
50
|
+
const body = await readBody(request);
|
|
51
|
+
const label = body.label ?? null;
|
|
52
|
+
if (label !== null && typeof label !== "string")
|
|
53
|
+
throw new HttpError(400, "label must be a string.");
|
|
54
|
+
let renamed;
|
|
55
|
+
try {
|
|
56
|
+
renamed = deps.store.rename(name, label, now);
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
if (error instanceof RangeError)
|
|
60
|
+
throw new HttpError(400, error.message);
|
|
61
|
+
throw error;
|
|
62
|
+
}
|
|
63
|
+
return json(200, summary(deps.store, found(renamed, name), now));
|
|
64
|
+
}
|
|
65
|
+
if (action === "/mark-available" && request.method === "POST") {
|
|
66
|
+
return json(200, summary(deps.store, found(deps.store.markAvailable(name, now), name), now));
|
|
67
|
+
}
|
|
68
|
+
if (action === "/auto-reset" && request.method === "POST") {
|
|
69
|
+
throw new HttpError(400, "Spending banked Codex resets is not supported by this gateway.");
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
if (error instanceof HttpError)
|
|
75
|
+
return json(error.status, { detail: error.message });
|
|
76
|
+
if (error instanceof SubscriptionOAuthInputError)
|
|
77
|
+
return json(400, { detail: error.message });
|
|
78
|
+
if (error instanceof SubscriptionOAuthProviderError)
|
|
79
|
+
return json(502, { detail: error.message });
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function listBody(store, now) {
|
|
84
|
+
const active = new Set(SUBSCRIPTION_PROVIDERS.map((provider) => store.activeCredential(provider, now)?.id));
|
|
85
|
+
return {
|
|
86
|
+
credentials: store.list().map((credential) => credentialBody(credential, active.has(credential.id), now)),
|
|
87
|
+
// Plan gating is hosted-only; a self-hosted gateway always allows routing.
|
|
88
|
+
autorouting_allowed: true,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
async function startSession(deps, body, now) {
|
|
92
|
+
const provider = body.provider;
|
|
93
|
+
if (typeof provider !== "string" || !SUBSCRIPTION_PROVIDERS.includes(provider)) {
|
|
94
|
+
throw new HttpError(400, `provider must be one of: ${SUBSCRIPTION_PROVIDERS.join(", ")}.`);
|
|
95
|
+
}
|
|
96
|
+
const credentialName = body.credential_name ?? null;
|
|
97
|
+
if (credentialName !== null && (typeof credentialName !== "string" || !credentialName.trim())) {
|
|
98
|
+
throw new HttpError(400, "credential_name must be a non-empty string.");
|
|
99
|
+
}
|
|
100
|
+
if (credentialName !== null && deps.store.getByName(credentialName)?.provider !== provider) {
|
|
101
|
+
throw new HttpError(404, `Unknown subscription credential ${JSON.stringify(credentialName)}.`);
|
|
102
|
+
}
|
|
103
|
+
const authorization = await startOAuth(provider, { fetch: deps.fetch, now });
|
|
104
|
+
const session = deps.store.createSession({
|
|
105
|
+
provider: provider,
|
|
106
|
+
flow: authorization.flow,
|
|
107
|
+
credentialName,
|
|
108
|
+
expiresAt: authorization.expiresAt,
|
|
109
|
+
}, now);
|
|
110
|
+
return {
|
|
111
|
+
provider,
|
|
112
|
+
session_token: session.token,
|
|
113
|
+
authorization_url: authorization.authorizationUrl,
|
|
114
|
+
expires_at: authorization.expiresAt.toISOString(),
|
|
115
|
+
user_code: authorization.userCode,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
async function completeSession(deps, body, now) {
|
|
119
|
+
const token = body.session_token;
|
|
120
|
+
const authorizationResponse = body.authorization_response ?? "";
|
|
121
|
+
if (typeof token !== "string" || !token || typeof authorizationResponse !== "string") {
|
|
122
|
+
throw new HttpError(400, "session_token and authorization_response must be strings.");
|
|
123
|
+
}
|
|
124
|
+
const session = deps.store.getSession(token);
|
|
125
|
+
if (session === null)
|
|
126
|
+
throw new HttpError(400, "Invalid subscription authorization session.");
|
|
127
|
+
if (session.expiresAt <= now) {
|
|
128
|
+
deps.store.deleteSession(token);
|
|
129
|
+
throw new HttpError(400, "Subscription authorization session has expired. Start again.");
|
|
130
|
+
}
|
|
131
|
+
// A pending device approval keeps the session so the CLI can retry.
|
|
132
|
+
const tokens = await completeOAuth(session.provider, session.flow, authorizationResponse, {
|
|
133
|
+
fetch: deps.fetch,
|
|
134
|
+
now,
|
|
135
|
+
});
|
|
136
|
+
deps.store.deleteSession(token);
|
|
137
|
+
const stored = deps.store.connect(tokens, session.credentialName, now);
|
|
138
|
+
if (stored === null) {
|
|
139
|
+
throw new HttpError(404, `Unknown subscription credential ${JSON.stringify(session.credentialName)}.`);
|
|
140
|
+
}
|
|
141
|
+
return summary(deps.store, stored, now);
|
|
142
|
+
}
|
|
143
|
+
function summary(store, credential, now) {
|
|
144
|
+
return credentialBody(credential, store.activeCredential(credential.provider, now)?.id === credential.id, now);
|
|
145
|
+
}
|
|
146
|
+
/** The fields the CLI reads from a credential, never its tokens. */
|
|
147
|
+
function credentialBody(credential, active, now) {
|
|
148
|
+
const held = isHeld(credential, now);
|
|
149
|
+
return {
|
|
150
|
+
id: credential.id,
|
|
151
|
+
name: credential.name,
|
|
152
|
+
label: credential.label,
|
|
153
|
+
safe_metadata: credential.accountEmail === null ? {} : { account_email: credential.accountEmail },
|
|
154
|
+
// Hosted organizations also hold organization-scoped secrets; every
|
|
155
|
+
// subscription here belongs to the gateway's one user.
|
|
156
|
+
credential_scope: "user",
|
|
157
|
+
oauth_provider: credential.provider,
|
|
158
|
+
created_at: credential.createdAt.toISOString(),
|
|
159
|
+
last_used_at: credential.lastUsedAt?.toISOString() ?? null,
|
|
160
|
+
unavailable_until: held ? credential.unavailableUntil.toISOString() : null,
|
|
161
|
+
unavailable_reason: held ? credential.unavailableReason : null,
|
|
162
|
+
subscription_active: active,
|
|
163
|
+
// Spending banked Codex resets is hosted-only.
|
|
164
|
+
auto_redeem_resets: false,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
function found(credential, name) {
|
|
168
|
+
if (credential === null)
|
|
169
|
+
throw new HttpError(404, `Unknown subscription credential ${JSON.stringify(name)}.`);
|
|
170
|
+
return credential;
|
|
171
|
+
}
|
|
172
|
+
async function readBody(request) {
|
|
173
|
+
let body;
|
|
174
|
+
try {
|
|
175
|
+
body = await request.json();
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
throw new HttpError(400, "Request body must be JSON.");
|
|
179
|
+
}
|
|
180
|
+
if (typeof body !== "object" || body === null || Array.isArray(body)) {
|
|
181
|
+
throw new HttpError(400, "Request body must be a JSON object.");
|
|
182
|
+
}
|
|
183
|
+
return body;
|
|
184
|
+
}
|
|
185
|
+
function json(status, body) {
|
|
186
|
+
return Response.json(body, { status });
|
|
187
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Personal subscriptions for a self-hosted, single-tenant gateway: Claude Code
|
|
3
|
+
* (Max/Pro) and ChatGPT (Codex) plans connected over OAuth and used in place
|
|
4
|
+
* of API keys.
|
|
5
|
+
*
|
|
6
|
+
* - `oauth.ts`: the providers' OAuth protocols (Claude Code PKCE with a
|
|
7
|
+
* pasted `code#state`, ChatGPT device code) and token refresh.
|
|
8
|
+
* - `store.ts`: SQLite storage. Tokens are plaintext in the gateway's own
|
|
9
|
+
* database file; protect that file like a secret.
|
|
10
|
+
* - `access.ts`: `freshAccessToken` refreshes a token expiring within five
|
|
11
|
+
* minutes before handing it out, one refresh per credential at a time.
|
|
12
|
+
* - `http.ts`: `handleSubscriptionRequest` serves the credential endpoints the
|
|
13
|
+
* Dari CLI uses to connect, list, rename, release, and disconnect
|
|
14
|
+
* subscriptions. The caller authenticates the request first.
|
|
15
|
+
*
|
|
16
|
+
* Routing picks `store.activeCredential(provider)`: the subscription in use
|
|
17
|
+
* while it serves, then the next one in connection order that is not held. When a provider reports a usage limit,
|
|
18
|
+
* `store.markUnavailable` holds that subscription until its reset and says
|
|
19
|
+
* whether another one can serve the retry.
|
|
20
|
+
*/
|
|
21
|
+
export { freshAccessToken } from "./access.js";
|
|
22
|
+
export { handleSubscriptionRequest, type SubscriptionHttpDeps } from "./http.js";
|
|
23
|
+
export { CODEX_APPROVAL_PENDING, SUBSCRIPTION_PROVIDERS, SubscriptionOAuthInputError, SubscriptionOAuthProviderError, completeOAuth, parseAuthorizationResponse, refreshOAuth, startOAuth, type OAuthAuthorization, type OAuthFetch, type OAuthOptions, type OAuthTokens, type SubscriptionProvider, } from "./oauth.js";
|
|
24
|
+
export { SubscriptionStore, isHeld, type PendingOAuthSession, type SubscriptionCredential, } from "./store.js";
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Personal subscriptions for a self-hosted, single-tenant gateway: Claude Code
|
|
3
|
+
* (Max/Pro) and ChatGPT (Codex) plans connected over OAuth and used in place
|
|
4
|
+
* of API keys.
|
|
5
|
+
*
|
|
6
|
+
* - `oauth.ts`: the providers' OAuth protocols (Claude Code PKCE with a
|
|
7
|
+
* pasted `code#state`, ChatGPT device code) and token refresh.
|
|
8
|
+
* - `store.ts`: SQLite storage. Tokens are plaintext in the gateway's own
|
|
9
|
+
* database file; protect that file like a secret.
|
|
10
|
+
* - `access.ts`: `freshAccessToken` refreshes a token expiring within five
|
|
11
|
+
* minutes before handing it out, one refresh per credential at a time.
|
|
12
|
+
* - `http.ts`: `handleSubscriptionRequest` serves the credential endpoints the
|
|
13
|
+
* Dari CLI uses to connect, list, rename, release, and disconnect
|
|
14
|
+
* subscriptions. The caller authenticates the request first.
|
|
15
|
+
*
|
|
16
|
+
* Routing picks `store.activeCredential(provider)`: the subscription in use
|
|
17
|
+
* while it serves, then the next one in connection order that is not held. When a provider reports a usage limit,
|
|
18
|
+
* `store.markUnavailable` holds that subscription until its reset and says
|
|
19
|
+
* whether another one can serve the retry.
|
|
20
|
+
*/
|
|
21
|
+
export { freshAccessToken } from "./access.js";
|
|
22
|
+
export { handleSubscriptionRequest } from "./http.js";
|
|
23
|
+
export { CODEX_APPROVAL_PENDING, SUBSCRIPTION_PROVIDERS, SubscriptionOAuthInputError, SubscriptionOAuthProviderError, completeOAuth, parseAuthorizationResponse, refreshOAuth, startOAuth, } from "./oauth.js";
|
|
24
|
+
export { SubscriptionStore, isHeld, } from "./store.js";
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
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`.
|
|
6
|
+
*/
|
|
7
|
+
export type SubscriptionProvider = "anthropic_claude_code" | "openai_codex";
|
|
8
|
+
export declare const SUBSCRIPTION_PROVIDERS: readonly SubscriptionProvider[];
|
|
9
|
+
export type OAuthFetch = (url: string, init: RequestInit) => Promise<Response>;
|
|
10
|
+
export type OAuthOptions = {
|
|
11
|
+
fetch: OAuthFetch;
|
|
12
|
+
now?: Date;
|
|
13
|
+
};
|
|
14
|
+
/** The provider refused or could not be reached (HTTP 502 at the edge). */
|
|
15
|
+
export declare class SubscriptionOAuthProviderError extends Error {
|
|
16
|
+
readonly status?: number | undefined;
|
|
17
|
+
constructor(message: string, status?: number | undefined);
|
|
18
|
+
}
|
|
19
|
+
/** The user's input or flow state is wrong (HTTP 400 at the edge). */
|
|
20
|
+
export declare class SubscriptionOAuthInputError extends Error {
|
|
21
|
+
constructor(message: string);
|
|
22
|
+
}
|
|
23
|
+
export type OAuthAuthorization = {
|
|
24
|
+
authorizationUrl: string;
|
|
25
|
+
expiresAt: Date;
|
|
26
|
+
userCode: string | null;
|
|
27
|
+
flow: Record<string, string>;
|
|
28
|
+
};
|
|
29
|
+
export type OAuthTokens = {
|
|
30
|
+
provider: SubscriptionProvider;
|
|
31
|
+
accessToken: string;
|
|
32
|
+
refreshToken: string;
|
|
33
|
+
expiresAt: Date;
|
|
34
|
+
accountId: string | null;
|
|
35
|
+
accountEmail: string | null;
|
|
36
|
+
};
|
|
37
|
+
export declare const CLAUDE_CODE_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
|
|
38
|
+
export declare const CODEX_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
|
|
39
|
+
export declare const CODEX_APPROVAL_PENDING = "OpenAI approval is still pending. Enter the code at OpenAI, finish approval, then try again.";
|
|
40
|
+
export declare function startOAuth(provider: SubscriptionProvider, options: OAuthOptions): Promise<OAuthAuthorization>;
|
|
41
|
+
/**
|
|
42
|
+
* Finish a flow. Claude Code needs the pasted authorization response; the
|
|
43
|
+
* device-code flow ignores it and asks OpenAI whether the user approved.
|
|
44
|
+
*/
|
|
45
|
+
export declare function completeOAuth(provider: SubscriptionProvider, flow: Record<string, string>, authorizationResponse: string, options: OAuthOptions): Promise<OAuthTokens>;
|
|
46
|
+
export declare function refreshOAuth(credential: {
|
|
47
|
+
provider: SubscriptionProvider;
|
|
48
|
+
refreshToken: string;
|
|
49
|
+
accountId: string | null;
|
|
50
|
+
}, options: OAuthOptions): Promise<OAuthTokens>;
|
|
51
|
+
/** Accepts the full callback URL, `code#state`, a `code=...&state=...` query, or a bare code. */
|
|
52
|
+
export declare function parseAuthorizationResponse(value: string): {
|
|
53
|
+
code: string;
|
|
54
|
+
state: string | null;
|
|
55
|
+
};
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
export const SUBSCRIPTION_PROVIDERS = [
|
|
3
|
+
"anthropic_claude_code",
|
|
4
|
+
"openai_codex",
|
|
5
|
+
];
|
|
6
|
+
/** The provider refused or could not be reached (HTTP 502 at the edge). */
|
|
7
|
+
export class SubscriptionOAuthProviderError extends Error {
|
|
8
|
+
status;
|
|
9
|
+
constructor(message, status) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.status = status;
|
|
12
|
+
this.name = "SubscriptionOAuthProviderError";
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
/** The user's input or flow state is wrong (HTTP 400 at the edge). */
|
|
16
|
+
export class SubscriptionOAuthInputError extends Error {
|
|
17
|
+
constructor(message) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.name = "SubscriptionOAuthInputError";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
const FLOW_TIMEOUT_MS = 15 * 60_000;
|
|
23
|
+
const REQUEST_TIMEOUT_MS = 30_000;
|
|
24
|
+
const INCOMPLETE = "Subscription OAuth provider returned an incomplete response.";
|
|
25
|
+
export const CLAUDE_CODE_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
|
|
26
|
+
const CLAUDE_AUTHORIZE_URL = "https://claude.com/cai/oauth/authorize";
|
|
27
|
+
const CLAUDE_TOKEN_URL = "https://platform.claude.com/v1/oauth/token";
|
|
28
|
+
// Anthropic shows `code#state` on its own callback page for the user to paste back.
|
|
29
|
+
const CLAUDE_REDIRECT_URI = "https://platform.claude.com/oauth/code/callback";
|
|
30
|
+
const CLAUDE_CODE_SCOPES = "user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload";
|
|
31
|
+
export const CODEX_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
|
|
32
|
+
const OPENAI_AUTH_BASE_URL = "https://auth.openai.com";
|
|
33
|
+
const CODEX_AUTHORIZE_URL = `${OPENAI_AUTH_BASE_URL}/codex/device`;
|
|
34
|
+
const CODEX_DEVICE_AUTH_URL = `${OPENAI_AUTH_BASE_URL}/api/accounts/deviceauth`;
|
|
35
|
+
const CODEX_TOKEN_URL = `${OPENAI_AUTH_BASE_URL}/oauth/token`;
|
|
36
|
+
const CODEX_REDIRECT_URI = `${OPENAI_AUTH_BASE_URL}/deviceauth/callback`;
|
|
37
|
+
const CODEX_ACCOUNT_CLAIM = "https://api.openai.com/auth";
|
|
38
|
+
export const CODEX_APPROVAL_PENDING = "OpenAI approval is still pending. Enter the code at OpenAI, finish approval, then try again.";
|
|
39
|
+
export async function startOAuth(provider, options) {
|
|
40
|
+
const expiresAt = new Date((options.now ?? new Date()).getTime() + FLOW_TIMEOUT_MS);
|
|
41
|
+
if (provider === "anthropic_claude_code") {
|
|
42
|
+
const verifier = randomUrlSafe(32);
|
|
43
|
+
const challenge = base64Url(createHash("sha256").update(verifier).digest());
|
|
44
|
+
// The pasted `code#state` is shown to the user, so state must not be the verifier.
|
|
45
|
+
const state = randomUrlSafe(32);
|
|
46
|
+
const query = new URLSearchParams({
|
|
47
|
+
code: "true",
|
|
48
|
+
client_id: CLAUDE_CODE_CLIENT_ID,
|
|
49
|
+
response_type: "code",
|
|
50
|
+
redirect_uri: CLAUDE_REDIRECT_URI,
|
|
51
|
+
scope: CLAUDE_CODE_SCOPES,
|
|
52
|
+
code_challenge: challenge,
|
|
53
|
+
code_challenge_method: "S256",
|
|
54
|
+
state,
|
|
55
|
+
});
|
|
56
|
+
return {
|
|
57
|
+
authorizationUrl: `${CLAUDE_AUTHORIZE_URL}?${query}`,
|
|
58
|
+
expiresAt,
|
|
59
|
+
userCode: null,
|
|
60
|
+
flow: { code_verifier: verifier, state },
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
const payload = await postJson(options.fetch, `${CODEX_DEVICE_AUTH_URL}/usercode`, { client_id: CODEX_CLIENT_ID }, "OpenAI device code request");
|
|
64
|
+
const userCode = requiredString({ user_code: payload.user_code || payload.usercode }, "user_code");
|
|
65
|
+
return {
|
|
66
|
+
authorizationUrl: CODEX_AUTHORIZE_URL,
|
|
67
|
+
expiresAt,
|
|
68
|
+
userCode,
|
|
69
|
+
flow: { device_auth_id: requiredString(payload, "device_auth_id"), user_code: userCode },
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Finish a flow. Claude Code needs the pasted authorization response; the
|
|
74
|
+
* device-code flow ignores it and asks OpenAI whether the user approved.
|
|
75
|
+
*/
|
|
76
|
+
export async function completeOAuth(provider, flow, authorizationResponse, options) {
|
|
77
|
+
const now = options.now ?? new Date();
|
|
78
|
+
if (provider === "anthropic_claude_code") {
|
|
79
|
+
const verifier = requiredString(flow, "code_verifier");
|
|
80
|
+
const expectedState = requiredString(flow, "state");
|
|
81
|
+
if (!authorizationResponse.trim()) {
|
|
82
|
+
throw new SubscriptionOAuthInputError("Anthropic authorization code is required.");
|
|
83
|
+
}
|
|
84
|
+
const { code, state } = parseAuthorizationResponse(authorizationResponse);
|
|
85
|
+
if (state !== null && state !== expectedState) {
|
|
86
|
+
throw new SubscriptionOAuthInputError("Anthropic authorization state does not match. Start again.");
|
|
87
|
+
}
|
|
88
|
+
const token = await postJson(options.fetch, CLAUDE_TOKEN_URL, {
|
|
89
|
+
grant_type: "authorization_code",
|
|
90
|
+
client_id: CLAUDE_CODE_CLIENT_ID,
|
|
91
|
+
code,
|
|
92
|
+
state: state ?? expectedState,
|
|
93
|
+
redirect_uri: CLAUDE_REDIRECT_URI,
|
|
94
|
+
code_verifier: verifier,
|
|
95
|
+
}, "Anthropic subscription token exchange");
|
|
96
|
+
return claudeTokens(token, now, null);
|
|
97
|
+
}
|
|
98
|
+
// The provider, not user-supplied input, supplies the authorization code.
|
|
99
|
+
let approval;
|
|
100
|
+
try {
|
|
101
|
+
approval = await postJson(options.fetch, `${CODEX_DEVICE_AUTH_URL}/token`, {
|
|
102
|
+
device_auth_id: requiredString(flow, "device_auth_id"),
|
|
103
|
+
user_code: requiredString(flow, "user_code"),
|
|
104
|
+
}, "OpenAI device approval check");
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
if (error instanceof SubscriptionOAuthProviderError && (error.status === 403 || error.status === 404)) {
|
|
108
|
+
throw new SubscriptionOAuthInputError(CODEX_APPROVAL_PENDING);
|
|
109
|
+
}
|
|
110
|
+
throw error;
|
|
111
|
+
}
|
|
112
|
+
const token = await postForm(options.fetch, CODEX_TOKEN_URL, {
|
|
113
|
+
grant_type: "authorization_code",
|
|
114
|
+
client_id: CODEX_CLIENT_ID,
|
|
115
|
+
code: requiredString(approval, "authorization_code"),
|
|
116
|
+
code_verifier: requiredString(approval, "code_verifier"),
|
|
117
|
+
redirect_uri: CODEX_REDIRECT_URI,
|
|
118
|
+
}, "OpenAI subscription token exchange");
|
|
119
|
+
return codexTokens(token, now, null, null);
|
|
120
|
+
}
|
|
121
|
+
export async function refreshOAuth(credential, options) {
|
|
122
|
+
const now = options.now ?? new Date();
|
|
123
|
+
if (credential.provider === "anthropic_claude_code") {
|
|
124
|
+
const token = await postJson(options.fetch, CLAUDE_TOKEN_URL, {
|
|
125
|
+
grant_type: "refresh_token",
|
|
126
|
+
client_id: CLAUDE_CODE_CLIENT_ID,
|
|
127
|
+
refresh_token: credential.refreshToken,
|
|
128
|
+
scope: CLAUDE_CODE_SCOPES,
|
|
129
|
+
}, "Anthropic subscription token refresh");
|
|
130
|
+
return claudeTokens(token, now, credential.refreshToken);
|
|
131
|
+
}
|
|
132
|
+
const token = await postJson(options.fetch, CODEX_TOKEN_URL, {
|
|
133
|
+
grant_type: "refresh_token",
|
|
134
|
+
refresh_token: credential.refreshToken,
|
|
135
|
+
client_id: CODEX_CLIENT_ID,
|
|
136
|
+
}, "OpenAI subscription token refresh");
|
|
137
|
+
return codexTokens(token, now, credential.refreshToken, credential.accountId);
|
|
138
|
+
}
|
|
139
|
+
/** Accepts the full callback URL, `code#state`, a `code=...&state=...` query, or a bare code. */
|
|
140
|
+
export function parseAuthorizationResponse(value) {
|
|
141
|
+
const normalized = value.trim();
|
|
142
|
+
if (URL.canParse(normalized)) {
|
|
143
|
+
const parsed = new URL(normalized);
|
|
144
|
+
const code = parsed.searchParams.get("code");
|
|
145
|
+
if (parsed.host && code)
|
|
146
|
+
return { code, state: parsed.searchParams.get("state") || null };
|
|
147
|
+
}
|
|
148
|
+
const hash = normalized.indexOf("#");
|
|
149
|
+
if (hash > 0) {
|
|
150
|
+
return { code: normalized.slice(0, hash), state: normalized.slice(hash + 1) || null };
|
|
151
|
+
}
|
|
152
|
+
if (normalized.includes("code=")) {
|
|
153
|
+
const query = new URLSearchParams(normalized);
|
|
154
|
+
const code = query.get("code");
|
|
155
|
+
if (code)
|
|
156
|
+
return { code, state: query.get("state") || null };
|
|
157
|
+
}
|
|
158
|
+
if (normalized)
|
|
159
|
+
return { code: normalized, state: null };
|
|
160
|
+
throw new SubscriptionOAuthInputError("Authorization code or redirect URL is required.");
|
|
161
|
+
}
|
|
162
|
+
function claudeTokens(payload, now, fallbackRefreshToken) {
|
|
163
|
+
const account = payload.account;
|
|
164
|
+
const email = isRecord(account) ? account.email_address : undefined;
|
|
165
|
+
return {
|
|
166
|
+
provider: "anthropic_claude_code",
|
|
167
|
+
accessToken: requiredString(payload, "access_token"),
|
|
168
|
+
refreshToken: refreshTokenFrom(payload, fallbackRefreshToken),
|
|
169
|
+
expiresAt: new Date(now.getTime() + positiveInt(payload.expires_in) * 1000),
|
|
170
|
+
accountId: null,
|
|
171
|
+
accountEmail: typeof email === "string" && email ? email : null,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
function codexTokens(payload, now, fallbackRefreshToken, fallbackAccountId) {
|
|
175
|
+
const accessToken = requiredString(payload, "access_token");
|
|
176
|
+
const refreshToken = refreshTokenFrom(payload, fallbackRefreshToken);
|
|
177
|
+
const idToken = typeof payload.id_token === "string" ? payload.id_token : null;
|
|
178
|
+
const accountId = (idToken === null ? null : codexAccountId(idToken))
|
|
179
|
+
?? codexAccountId(accessToken)
|
|
180
|
+
?? fallbackAccountId;
|
|
181
|
+
if (accountId === null) {
|
|
182
|
+
throw new SubscriptionOAuthProviderError("OpenAI subscription token is missing its account identifier.");
|
|
183
|
+
}
|
|
184
|
+
const email = idToken === null ? undefined : jwtPayload(idToken)?.email;
|
|
185
|
+
return {
|
|
186
|
+
provider: "openai_codex",
|
|
187
|
+
accessToken,
|
|
188
|
+
refreshToken,
|
|
189
|
+
expiresAt: codexExpiration(payload, accessToken, now),
|
|
190
|
+
accountId,
|
|
191
|
+
accountEmail: typeof email === "string" && email ? email : null,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
function codexAccountId(token) {
|
|
195
|
+
const auth = jwtPayload(token)?.[CODEX_ACCOUNT_CLAIM];
|
|
196
|
+
const accountId = isRecord(auth) ? auth.chatgpt_account_id : undefined;
|
|
197
|
+
return typeof accountId === "string" && accountId ? accountId : null;
|
|
198
|
+
}
|
|
199
|
+
function codexExpiration(payload, accessToken, now) {
|
|
200
|
+
if (payload.expires_in !== undefined && payload.expires_in !== null) {
|
|
201
|
+
return new Date(now.getTime() + positiveInt(payload.expires_in) * 1000);
|
|
202
|
+
}
|
|
203
|
+
const exp = jwtPayload(accessToken)?.exp;
|
|
204
|
+
if (typeof exp === "number" && Number.isFinite(exp))
|
|
205
|
+
return new Date(exp * 1000);
|
|
206
|
+
throw new SubscriptionOAuthProviderError("OpenAI subscription token is missing its expiration.");
|
|
207
|
+
}
|
|
208
|
+
function refreshTokenFrom(payload, fallback) {
|
|
209
|
+
const value = payload.refresh_token;
|
|
210
|
+
const refreshToken = typeof value === "string" && value ? value : fallback;
|
|
211
|
+
if (!refreshToken)
|
|
212
|
+
throw new SubscriptionOAuthProviderError(INCOMPLETE);
|
|
213
|
+
return refreshToken;
|
|
214
|
+
}
|
|
215
|
+
function requiredString(payload, name) {
|
|
216
|
+
const value = payload[name];
|
|
217
|
+
if (typeof value !== "string" || !value)
|
|
218
|
+
throw new SubscriptionOAuthProviderError(INCOMPLETE);
|
|
219
|
+
return value;
|
|
220
|
+
}
|
|
221
|
+
function positiveInt(value) {
|
|
222
|
+
const parsed = typeof value === "string" && /^\s*[+-]?\d+\s*$/.test(value) ? Number(value) : value;
|
|
223
|
+
if (typeof parsed === "number" && Number.isInteger(parsed) && parsed > 0)
|
|
224
|
+
return parsed;
|
|
225
|
+
throw new SubscriptionOAuthProviderError(INCOMPLETE);
|
|
226
|
+
}
|
|
227
|
+
function jwtPayload(token) {
|
|
228
|
+
const parts = token.split(".");
|
|
229
|
+
if (parts.length !== 3)
|
|
230
|
+
return null;
|
|
231
|
+
try {
|
|
232
|
+
const decoded = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
|
|
233
|
+
return isRecord(decoded) ? decoded : null;
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
function postJson(fetch, url, payload, operation) {
|
|
240
|
+
return requestJson(fetch, url, JSON.stringify(payload), "application/json", operation);
|
|
241
|
+
}
|
|
242
|
+
function postForm(fetch, url, payload, operation) {
|
|
243
|
+
return requestJson(fetch, url, new URLSearchParams(payload).toString(), "application/x-www-form-urlencoded", operation);
|
|
244
|
+
}
|
|
245
|
+
async function requestJson(fetch, url, body, contentType, operation) {
|
|
246
|
+
let response;
|
|
247
|
+
try {
|
|
248
|
+
response = await fetch(url, {
|
|
249
|
+
method: "POST",
|
|
250
|
+
headers: { Accept: "application/json", "Content-Type": contentType, "User-Agent": "Dari/1.0" },
|
|
251
|
+
body,
|
|
252
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
throw new SubscriptionOAuthProviderError(`${operation} could not reach the provider.`);
|
|
257
|
+
}
|
|
258
|
+
if (!response.ok) {
|
|
259
|
+
await response.body?.cancel();
|
|
260
|
+
throw new SubscriptionOAuthProviderError(`${operation} failed with provider status ${response.status}.`, response.status);
|
|
261
|
+
}
|
|
262
|
+
let decoded;
|
|
263
|
+
try {
|
|
264
|
+
decoded = await response.json();
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
throw new SubscriptionOAuthProviderError(`${operation} returned an invalid response.`);
|
|
268
|
+
}
|
|
269
|
+
if (!isRecord(decoded))
|
|
270
|
+
throw new SubscriptionOAuthProviderError(`${operation} returned an invalid response.`);
|
|
271
|
+
return decoded;
|
|
272
|
+
}
|
|
273
|
+
function randomUrlSafe(size) {
|
|
274
|
+
return base64Url(randomBytes(size));
|
|
275
|
+
}
|
|
276
|
+
function base64Url(value) {
|
|
277
|
+
return value.toString("base64url");
|
|
278
|
+
}
|
|
279
|
+
function isRecord(value) {
|
|
280
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
281
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SQLite storage for personal subscription credentials and pending OAuth flows.
|
|
3
|
+
*
|
|
4
|
+
* Tokens are stored as PLAINTEXT in the gateway's own database file. Anyone
|
|
5
|
+
* who can read that file can use the connected subscriptions until the
|
|
6
|
+
* provider revokes them, so keep the file on storage only the gateway's
|
|
7
|
+
* operator can read, and treat backups of it as secrets.
|
|
8
|
+
*
|
|
9
|
+
* Connection order (`position`) is the order routing switches through: the
|
|
10
|
+
* active credential for a provider is the first one, in that order, that is
|
|
11
|
+
* not held. A hold ends on its own once `unavailable_until` passes.
|
|
12
|
+
*/
|
|
13
|
+
import type { Database } from "bun:sqlite";
|
|
14
|
+
import type { OAuthTokens, SubscriptionProvider } from "./oauth.js";
|
|
15
|
+
export type SubscriptionCredential = {
|
|
16
|
+
id: string;
|
|
17
|
+
provider: SubscriptionProvider;
|
|
18
|
+
name: string;
|
|
19
|
+
label: string | null;
|
|
20
|
+
accountEmail: string | null;
|
|
21
|
+
accountId: string | null;
|
|
22
|
+
accessToken: string;
|
|
23
|
+
refreshToken: string;
|
|
24
|
+
expiresAt: Date;
|
|
25
|
+
unavailableUntil: Date | null;
|
|
26
|
+
unavailableReason: string | null;
|
|
27
|
+
position: number;
|
|
28
|
+
createdAt: Date;
|
|
29
|
+
updatedAt: Date;
|
|
30
|
+
lastUsedAt: Date | null;
|
|
31
|
+
};
|
|
32
|
+
export type PendingOAuthSession = {
|
|
33
|
+
token: string;
|
|
34
|
+
provider: SubscriptionProvider;
|
|
35
|
+
flow: Record<string, string>;
|
|
36
|
+
credentialName: string | null;
|
|
37
|
+
expiresAt: Date;
|
|
38
|
+
};
|
|
39
|
+
export declare class SubscriptionStore {
|
|
40
|
+
private readonly db;
|
|
41
|
+
constructor(db: Database);
|
|
42
|
+
/** Every subscription, grouped by provider, in connection order. */
|
|
43
|
+
list(provider?: SubscriptionProvider): SubscriptionCredential[];
|
|
44
|
+
get(id: string): SubscriptionCredential | null;
|
|
45
|
+
getByName(name: string): SubscriptionCredential | null;
|
|
46
|
+
/** The credential routing uses now, or null when none is connected or every one is held. */
|
|
47
|
+
activeCredential(provider: SubscriptionProvider, now?: Date): SubscriptionCredential | null;
|
|
48
|
+
/**
|
|
49
|
+
* Store tokens from a completed OAuth flow. `reconnectName` replaces that
|
|
50
|
+
* subscription (null when it is not connected). Otherwise a subscription
|
|
51
|
+
* for the same account email is replaced, and anything else is connected
|
|
52
|
+
* last in order under the first free name.
|
|
53
|
+
*/
|
|
54
|
+
connect(tokens: OAuthTokens, reconnectName: string | null, now?: Date): SubscriptionCredential | null;
|
|
55
|
+
/** Persist refreshed tokens. Keeps the stored account id when the refresh carries none. */
|
|
56
|
+
updateTokens(id: string, tokens: OAuthTokens, now?: Date): void;
|
|
57
|
+
touch(id: string, now?: Date): void;
|
|
58
|
+
/** An empty label clears it. Null when the subscription is not connected. */
|
|
59
|
+
rename(name: string, label: string | null, now?: Date): SubscriptionCredential | null;
|
|
60
|
+
/** Remove a subscription. Returns what was removed, or null when it was not connected. */
|
|
61
|
+
disconnect(name: string): SubscriptionCredential | null;
|
|
62
|
+
/**
|
|
63
|
+
* Hold an exhausted subscription until its reset. A hold only grows: a later
|
|
64
|
+
* report with an earlier reset never shortens a longer one. Returns whether
|
|
65
|
+
* a subscription for the same provider is available now.
|
|
66
|
+
*/
|
|
67
|
+
markUnavailable(id: string, until: Date, reason: string, now?: Date): boolean;
|
|
68
|
+
/** Clear a usage-limit hold. Null when the subscription is not connected. */
|
|
69
|
+
markAvailable(name: string, now?: Date): SubscriptionCredential | null;
|
|
70
|
+
createSession(session: Omit<PendingOAuthSession, "token">, now?: Date): PendingOAuthSession;
|
|
71
|
+
/** A pending session, including an expired one so callers can say it expired. */
|
|
72
|
+
getSession(token: string): PendingOAuthSession | null;
|
|
73
|
+
deleteSession(token: string): void;
|
|
74
|
+
private freeName;
|
|
75
|
+
}
|
|
76
|
+
export declare function isHeld(credential: SubscriptionCredential, now: Date): boolean;
|
|
77
|
+
/** Subscription names are env-var-style identifiers; matching is case-insensitive. */
|
|
78
|
+
export declare function normalizeName(name: string): string;
|