@stage-labs/metro 0.1.0-beta.9 → 0.1.0-beta.91
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/api.js +14 -0
- package/dist/claude-settings.js +40 -0
- package/dist/claude.js +129 -0
- package/dist/cli.js +101 -0
- package/dist/control.js +74 -0
- package/dist/hold.js +211 -0
- package/dist/local.js +93 -0
- package/dist/node-name.js +71 -0
- package/dist/plugin.js +80 -0
- package/dist/provider-flags.js +9 -0
- package/dist/runtime-install.js +87 -0
- package/dist/runtime.js +74 -0
- package/dist/serve.js +150 -0
- package/dist/service.js +242 -0
- package/dist/tail.js +119 -0
- package/dist/update.js +45 -0
- package/dist/version.js +97 -0
- package/package.json +14 -25
- package/runtime/node_modules/@metro-labs/core/package.json +43 -0
- package/runtime/node_modules/@metro-labs/core/src/address.ts +6 -0
- package/runtime/node_modules/@metro-labs/core/src/endpoints.ts +7 -0
- package/runtime/node_modules/@metro-labs/core/src/events.ts +120 -0
- package/runtime/node_modules/@metro-labs/core/src/identity.ts +7 -0
- package/runtime/node_modules/@metro-labs/core/src/ids.ts +21 -0
- package/runtime/node_modules/@metro-labs/core/src/is-record.ts +2 -0
- package/runtime/node_modules/@metro-labs/core/src/lines.ts +82 -0
- package/runtime/node_modules/@metro-labs/core/src/log.ts +42 -0
- package/runtime/node_modules/@metro-labs/core/src/protocol.ts +226 -0
- package/runtime/node_modules/@metro-labs/core/src/secure-fs.ts +57 -0
- package/runtime/node_modules/@metro-labs/core/src/station-names.ts +12 -0
- package/runtime/node_modules/@metro-labs/core/src/stations/account-store.ts +60 -0
- package/runtime/node_modules/@metro-labs/core/src/stations/attachments.ts +196 -0
- package/runtime/node_modules/@metro-labs/core/src/stations/messaging-normalize.ts +88 -0
- package/runtime/node_modules/@metro-labs/core/src/stations/station-runtime.ts +64 -0
- package/runtime/node_modules/@metro-labs/core/src/stations/types.ts +107 -0
- package/runtime/node_modules/@metro-labs/core/src/str.ts +5 -0
- package/runtime/node_modules/@metro-labs/core/src/train-error.ts +53 -0
- package/runtime/node_modules/@metro-labs/core/src/version.ts +2 -0
- package/runtime/node_modules/@metro-labs/daemon/package.json +47 -0
- package/runtime/node_modules/@metro-labs/daemon/src/agents/account-attach.ts +19 -0
- package/runtime/node_modules/@metro-labs/daemon/src/agents/accounts-api.ts +368 -0
- package/runtime/node_modules/@metro-labs/daemon/src/agents/admin.ts +43 -0
- package/runtime/node_modules/@metro-labs/daemon/src/agents/allowlist.ts +37 -0
- package/runtime/node_modules/@metro-labs/daemon/src/agents/api.ts +302 -0
- package/runtime/node_modules/@metro-labs/daemon/src/agents/bundle.ts +111 -0
- package/runtime/node_modules/@metro-labs/daemon/src/agents/file-admin.ts +345 -0
- package/runtime/node_modules/@metro-labs/daemon/src/agents/files.ts +155 -0
- package/runtime/node_modules/@metro-labs/daemon/src/agents/keys.ts +45 -0
- package/runtime/node_modules/@metro-labs/daemon/src/agents/map.ts +88 -0
- package/runtime/node_modules/@metro-labs/daemon/src/agents/scope.ts +64 -0
- package/runtime/node_modules/@metro-labs/daemon/src/agents/senders.ts +38 -0
- package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +193 -0
- package/runtime/node_modules/@metro-labs/daemon/src/boot/connect-hint.ts +39 -0
- package/runtime/node_modules/@metro-labs/daemon/src/boot/crash-guard.ts +49 -0
- package/runtime/node_modules/@metro-labs/daemon/src/boot/local-owner.ts +14 -0
- package/runtime/node_modules/@metro-labs/daemon/src/boot/paths.ts +106 -0
- package/runtime/node_modules/@metro-labs/daemon/src/channels/addressed.ts +25 -0
- package/runtime/node_modules/@metro-labs/daemon/src/channels/dedupe.ts +24 -0
- package/runtime/node_modules/@metro-labs/daemon/src/channels/inbound.ts +377 -0
- package/runtime/node_modules/@metro-labs/daemon/src/channels/media-note.ts +85 -0
- package/runtime/node_modules/@metro-labs/daemon/src/channels/pending.ts +75 -0
- package/runtime/node_modules/@metro-labs/daemon/src/channels/relay.ts +129 -0
- package/runtime/node_modules/@metro-labs/daemon/src/channels/webhook-note.ts +51 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/api.ts +176 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/files.ts +329 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/login.ts +167 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/settings.ts +117 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/skills.ts +154 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/api.ts +307 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/cli-api.ts +63 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/config.ts +144 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/json.ts +21 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/model.ts +69 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/oauth-client.ts +159 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/oauth-discovery.ts +117 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/oauth-pending.ts +65 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/oauth-routes.ts +157 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/oauth.ts +127 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/plugin-sync.ts +112 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-target.ts +39 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay.ts +295 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/store.ts +277 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/tools.ts +98 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/url.ts +70 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/verify.ts +330 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/watch.ts +54 -0
- package/runtime/node_modules/@metro-labs/daemon/src/files/attach-grant.ts +99 -0
- package/runtime/node_modules/@metro-labs/daemon/src/files/attach-owner.ts +32 -0
- package/runtime/node_modules/@metro-labs/daemon/src/files/attach-serve.ts +121 -0
- package/runtime/node_modules/@metro-labs/daemon/src/files/upload-api.ts +294 -0
- package/runtime/node_modules/@metro-labs/daemon/src/files/upload-store.ts +181 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/bedrock.ts +252 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/codex-auth.ts +231 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/codex-device.ts +91 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/codex-stream.ts +288 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/codex-translate.ts +203 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/codex.ts +220 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/eventstream.ts +123 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/forward.ts +182 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gateway.ts +208 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-api.ts +195 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-config.ts +198 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/openrouter.ts +40 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/served.ts +17 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/accounts.ts +139 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/call-tools.ts +216 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/channel-owner.ts +29 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/ctx.ts +62 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/event-store.ts +91 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/frame-scope.ts +21 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/group-tools.ts +129 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/index.ts +191 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/member-tools.ts +72 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/permission-relay.ts +66 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/raw-get-stream.ts +207 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/request-identity.ts +47 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/session-registry.ts +172 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/session-route.ts +43 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/session.ts +215 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/tool-dispatch.ts +215 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/tool-schemas.ts +400 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/upload-tool.ts +66 -0
- package/runtime/node_modules/@metro-labs/daemon/src/monitor/api.ts +237 -0
- package/runtime/node_modules/@metro-labs/daemon/src/net/tunnel.ts +389 -0
- package/runtime/node_modules/@metro-labs/daemon/src/routes/http.ts +390 -0
- package/runtime/node_modules/@metro-labs/daemon/src/routes/identity.ts +54 -0
- package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +198 -0
- package/runtime/node_modules/@metro-labs/daemon/src/routes/session-apis.ts +60 -0
- package/runtime/node_modules/@metro-labs/daemon/src/routes/session.ts +29 -0
- package/runtime/node_modules/@metro-labs/daemon/src/server/control.ts +64 -0
- package/runtime/node_modules/@metro-labs/daemon/src/server/machine.ts +61 -0
- package/runtime/node_modules/@metro-labs/daemon/src/server/update.ts +116 -0
- package/runtime/node_modules/@metro-labs/daemon/src/server.ts +2 -0
- package/runtime/node_modules/@metro-labs/daemon/src/stations/attach-inline.ts +100 -0
- package/runtime/node_modules/@metro-labs/daemon/src/stations/attach-interactive.ts +161 -0
- package/runtime/node_modules/@metro-labs/daemon/src/stations/attach-resolve.ts +202 -0
- package/runtime/node_modules/@metro-labs/daemon/src/stations/attach-session.ts +280 -0
- package/runtime/node_modules/@metro-labs/daemon/src/stations/attach-xmtp.ts +111 -0
- package/runtime/node_modules/@metro-labs/daemon/src/stations/attach.ts +184 -0
- package/runtime/node_modules/@metro-labs/daemon/src/stations/materialize.ts +248 -0
- package/runtime/node_modules/@metro-labs/daemon/src/stations/registry.ts +35 -0
- package/runtime/node_modules/@metro-labs/daemon/src/stations/runtime-deps.ts +82 -0
- package/runtime/node_modules/@metro-labs/daemon/src/stations/supervisor-io.ts +97 -0
- package/runtime/node_modules/@metro-labs/daemon/src/stations/supervisor.ts +320 -0
- package/runtime/node_modules/@metro-labs/daemon/src/stations/train-call.ts +22 -0
- package/runtime/node_modules/@metro-labs/daemon/src/terminal/api.ts +91 -0
- package/runtime/node_modules/@metro-labs/daemon/src/terminal/socket.ts +123 -0
- package/runtime/node_modules/@metro-labs/daemon/src/terminal/tickets.ts +40 -0
- package/runtime/node_modules/@metro-labs/discord-bot/package.json +28 -0
- package/runtime/node_modules/@metro-labs/discord-bot/src/accounts.ts +118 -0
- package/runtime/node_modules/@metro-labs/discord-bot/src/actions.ts +388 -0
- package/runtime/node_modules/@metro-labs/discord-bot/src/api-base.ts +1 -0
- package/runtime/node_modules/@metro-labs/discord-bot/src/attachments.ts +29 -0
- package/runtime/node_modules/@metro-labs/discord-bot/src/format.ts +220 -0
- package/runtime/node_modules/@metro-labs/discord-bot/src/group-actions.ts +50 -0
- package/runtime/node_modules/@metro-labs/discord-bot/src/group.ts +115 -0
- package/runtime/node_modules/@metro-labs/discord-bot/src/index.ts +127 -0
- package/runtime/node_modules/@metro-labs/discord-bot/src/members.ts +215 -0
- package/runtime/node_modules/@metro-labs/discord-bot/src/send-files.ts +43 -0
- package/runtime/node_modules/@metro-labs/discord-bot/src/station.ts +19 -0
- package/runtime/node_modules/@metro-labs/discord-bot/src/verify.ts +82 -0
- package/runtime/node_modules/@metro-labs/discord-bot/src/voice-speak.ts +195 -0
- package/runtime/node_modules/@metro-labs/discord-bot/src/voice-transcribe.ts +292 -0
- package/runtime/node_modules/@metro-labs/discord-bot/src/voice.ts +197 -0
- package/runtime/node_modules/@metro-labs/discord-bot/src/wire.ts +5 -0
- package/runtime/node_modules/@metro-labs/http/package.json +33 -0
- package/runtime/node_modules/@metro-labs/http/src/api-error.ts +8 -0
- package/runtime/node_modules/@metro-labs/http/src/api-http.ts +95 -0
- package/runtime/node_modules/@metro-labs/http/src/cors.ts +29 -0
- package/runtime/node_modules/@metro-labs/http/src/identity-key.ts +33 -0
- package/runtime/node_modules/@metro-labs/http/src/identity-registry.ts +13 -0
- package/runtime/node_modules/@metro-labs/http/src/mode-api.ts +32 -0
- package/runtime/node_modules/@metro-labs/http/src/return-to.ts +21 -0
- package/runtime/node_modules/@metro-labs/http/src/signed-identity.ts +36 -0
- package/runtime/node_modules/@metro-labs/telegram/package.json +26 -0
- package/runtime/node_modules/@metro-labs/telegram/src/accounts.ts +80 -0
- package/runtime/node_modules/@metro-labs/telegram/src/actions.ts +254 -0
- package/runtime/node_modules/@metro-labs/telegram/src/attachments.ts +87 -0
- package/runtime/node_modules/@metro-labs/telegram/src/client.ts +77 -0
- package/runtime/node_modules/@metro-labs/telegram/src/format.ts +156 -0
- package/runtime/node_modules/@metro-labs/telegram/src/group.ts +261 -0
- package/runtime/node_modules/@metro-labs/telegram/src/history.ts +52 -0
- package/runtime/node_modules/@metro-labs/telegram/src/inbound.ts +78 -0
- package/runtime/node_modules/@metro-labs/telegram/src/index.ts +46 -0
- package/runtime/node_modules/@metro-labs/telegram/src/login.ts +166 -0
- package/runtime/node_modules/@metro-labs/telegram/src/media-actions.ts +80 -0
- package/runtime/node_modules/@metro-labs/telegram/src/members.ts +75 -0
- package/runtime/node_modules/@metro-labs/telegram/src/normalize.ts +22 -0
- package/runtime/node_modules/@metro-labs/telegram/src/reactions.ts +167 -0
- package/runtime/node_modules/@metro-labs/telegram/src/station.ts +24 -0
- package/runtime/node_modules/@metro-labs/telegram/src/types.ts +7 -0
- package/runtime/node_modules/@metro-labs/telegram/src/wire.ts +1 -0
- package/runtime/node_modules/@metro-labs/telegram-bot/package.json +25 -0
- package/runtime/node_modules/@metro-labs/telegram-bot/src/accounts.ts +120 -0
- package/runtime/node_modules/@metro-labs/telegram-bot/src/actions.ts +295 -0
- package/runtime/node_modules/@metro-labs/telegram-bot/src/attachments.ts +128 -0
- package/runtime/node_modules/@metro-labs/telegram-bot/src/format.ts +201 -0
- package/runtime/node_modules/@metro-labs/telegram-bot/src/index.ts +109 -0
- package/runtime/node_modules/@metro-labs/telegram-bot/src/media-actions.ts +166 -0
- package/runtime/node_modules/@metro-labs/telegram-bot/src/members.ts +47 -0
- package/runtime/node_modules/@metro-labs/telegram-bot/src/station.ts +17 -0
- package/runtime/node_modules/@metro-labs/telegram-bot/src/types.ts +58 -0
- package/runtime/node_modules/@metro-labs/telegram-bot/src/verify.ts +38 -0
- package/runtime/node_modules/@metro-labs/telegram-bot/src/wire.ts +3 -0
- package/runtime/node_modules/@metro-labs/webhook/package.json +23 -0
- package/runtime/node_modules/@metro-labs/webhook/src/station.ts +37 -0
- package/runtime/node_modules/@metro-labs/whatsapp/package.json +27 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/accounts.ts +66 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/ack.ts +101 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/actions.ts +178 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/attachments.ts +72 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/auth-state.ts +129 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/client.ts +327 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/delivery.ts +49 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/format.ts +138 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/inbound.ts +57 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/index.ts +40 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/keys.ts +59 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/logger.ts +60 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/login.ts +176 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/media.ts +84 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/normalize.ts +22 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/outbox.ts +38 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/parse.ts +185 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/station.ts +17 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/token-store.ts +98 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/types.ts +6 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/wire.ts +5 -0
- package/runtime/node_modules/@metro-labs/xmtp/package.json +33 -0
- package/runtime/node_modules/@metro-labs/xmtp/src/accounts.ts +112 -0
- package/runtime/node_modules/@metro-labs/xmtp/src/actions-close.ts +110 -0
- package/runtime/node_modules/@metro-labs/xmtp/src/actions-conv.ts +299 -0
- package/runtime/node_modules/@metro-labs/xmtp/src/actions-meta.ts +157 -0
- package/runtime/node_modules/@metro-labs/xmtp/src/actions-push.ts +87 -0
- package/runtime/node_modules/@metro-labs/xmtp/src/actions.ts +375 -0
- package/runtime/node_modules/@metro-labs/xmtp/src/attachments.ts +107 -0
- package/runtime/node_modules/@metro-labs/xmtp/src/codecs.ts +171 -0
- package/runtime/node_modules/@metro-labs/xmtp/src/conv-helpers.ts +239 -0
- package/runtime/node_modules/@metro-labs/xmtp/src/emit-core.ts +55 -0
- package/runtime/node_modules/@metro-labs/xmtp/src/emit-payloads.ts +335 -0
- package/runtime/node_modules/@metro-labs/xmtp/src/emit.ts +80 -0
- package/runtime/node_modules/@metro-labs/xmtp/src/group-members.ts +34 -0
- package/runtime/node_modules/@metro-labs/xmtp/src/group.ts +56 -0
- package/runtime/node_modules/@metro-labs/xmtp/src/identity.ts +33 -0
- package/runtime/node_modules/@metro-labs/xmtp/src/index.ts +125 -0
- package/runtime/node_modules/@metro-labs/xmtp/src/labels.ts +157 -0
- package/runtime/node_modules/@metro-labs/xmtp/src/member-args.ts +40 -0
- package/runtime/node_modules/@metro-labs/xmtp/src/push.ts +349 -0
- package/runtime/node_modules/@metro-labs/xmtp/src/station.ts +13 -0
- package/runtime/node_modules/@metro-labs/xmtp/src/tools-handlers.ts +126 -0
- package/runtime/node_modules/@metro-labs/xmtp/src/tools.ts +212 -0
- package/runtime/node_modules/@metro-labs/xmtp/src/transcribe.ts +90 -0
- package/runtime/node_modules/@metro-labs/xmtp/src/verify.ts +84 -0
- package/runtime/node_modules/@metro-labs/xmtp/src/wire.ts +53 -0
- package/runtime/runtime.json +3 -0
- package/runtime/server.ts +1 -0
- package/runtime/stations.json +36 -0
- package/runtime/trains/.keep +0 -0
- package/LICENSE +0 -21
- package/README.md +0 -249
- package/dist/cache.js +0 -75
- package/dist/cli/actions.js +0 -139
- package/dist/cli/config.js +0 -185
- package/dist/cli/index.js +0 -177
- package/dist/cli/skill.js +0 -62
- package/dist/cli/util.js +0 -72
- package/dist/cli/webhook.js +0 -83
- package/dist/codex-rc.js +0 -245
- package/dist/dispatcher.js +0 -122
- package/dist/history.js +0 -99
- package/dist/ipc.js +0 -72
- package/dist/log.js +0 -15
- package/dist/paths.js +0 -68
- package/dist/registry.js +0 -48
- package/dist/stations/claude.js +0 -45
- package/dist/stations/codex.js +0 -68
- package/dist/stations/discord.js +0 -200
- package/dist/stations/index.js +0 -133
- package/dist/stations/telegram-md.js +0 -34
- package/dist/stations/telegram-upload.js +0 -113
- package/dist/stations/telegram.js +0 -213
- package/dist/stations/webhook.js +0 -100
- package/dist/tunnel.js +0 -66
- package/dist/webhooks.js +0 -40
- package/docs/agents.md +0 -216
- package/docs/uri-scheme.md +0 -125
- package/skills/metro/SKILL.md +0 -249
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
import { errMsg, log } from '@metro-labs/core/log';
|
|
3
|
+
import { agentIdForKey } from '../agents/keys.js';
|
|
4
|
+
import {
|
|
5
|
+
assertBedrockReady,
|
|
6
|
+
bedrockBase,
|
|
7
|
+
bedrockCount,
|
|
8
|
+
bedrockMessages,
|
|
9
|
+
freshAdaptations,
|
|
10
|
+
type Adaptations,
|
|
11
|
+
} from './bedrock.js';
|
|
12
|
+
import { anthropicHeaders, forwardedHeaders, GatewayError, parseJson, pipeResponse, readBody, sendError, watchUpstream } from './forward.js';
|
|
13
|
+
import { notReady, readModelConfig, resolveRoute, routeLabel, setCodexAuth, writeModelConfig, type ModelConfig, type Route } from './model-config.js';
|
|
14
|
+
import { codexCount, codexMessages, freshCodexState, type CodexDeps } from './codex.js';
|
|
15
|
+
import { OPENROUTER_BASE } from './openrouter.js';
|
|
16
|
+
import { forgetServed, noteServed } from './served.js';
|
|
17
|
+
import type { CodexTokens } from './codex-auth.js';
|
|
18
|
+
|
|
19
|
+
export const GATEWAY_PREFIX = '/gateway';
|
|
20
|
+
export const ANTHROPIC_BASE = 'https://api.anthropic.com';
|
|
21
|
+
const MESSAGES = '/v1/messages';
|
|
22
|
+
const COUNT = '/v1/messages/count_tokens';
|
|
23
|
+
const MODELS = '/v1/models';
|
|
24
|
+
const HELLO = '/api/hello';
|
|
25
|
+
|
|
26
|
+
export interface GatewayDeps {
|
|
27
|
+
config: () => ModelConfig;
|
|
28
|
+
identify?: (key: string) => boolean;
|
|
29
|
+
anthropicBase?: string;
|
|
30
|
+
bedrockBase?: string;
|
|
31
|
+
openrouterBase?: string;
|
|
32
|
+
codex?: Partial<CodexDeps>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const learned: Adaptations = freshAdaptations();
|
|
36
|
+
const codexState = freshCodexState();
|
|
37
|
+
|
|
38
|
+
export function resetGatewayState(): void {
|
|
39
|
+
forgetServed();
|
|
40
|
+
learned.fields.clear();
|
|
41
|
+
learned.dropBetas = false;
|
|
42
|
+
Object.assign(codexState, freshCodexState());
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const saveCodexTokens = (tokens: CodexTokens): void => {
|
|
46
|
+
writeModelConfig(setCodexAuth(readModelConfig(), tokens));
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const keyOf = (req: IncomingMessage): string => {
|
|
50
|
+
const raw = req.headers['x-metro-key'];
|
|
51
|
+
return (Array.isArray(raw) ? raw[0] : raw)?.trim() ?? '';
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const defaultIdentify = (key: string): boolean => key !== '' && agentIdForKey(key) !== undefined;
|
|
55
|
+
|
|
56
|
+
function modelsBody(cfg: ModelConfig): Record<string, unknown> {
|
|
57
|
+
const data: Record<string, string>[] = [];
|
|
58
|
+
if (cfg.bedrock.model !== '')
|
|
59
|
+
data.push({ id: `bedrock:${cfg.bedrock.model}`, display_name: `Bedrock · ${cfg.bedrock.model}`, description: 'Through metro, billed to Amazon Bedrock' });
|
|
60
|
+
if (cfg.openrouter.model !== '')
|
|
61
|
+
data.push({ id: `openrouter:${cfg.openrouter.model}`, display_name: `OpenRouter · ${cfg.openrouter.model}`, description: 'Through metro, billed to OpenRouter' });
|
|
62
|
+
if (cfg.codex.model !== '')
|
|
63
|
+
data.push({ id: `codex:${cfg.codex.model}`, display_name: `Codex · ${cfg.codex.model}`, description: 'Through metro, on your ChatGPT subscription' });
|
|
64
|
+
return { data, has_more: false, first_id: data[0]?.id ?? null, last_id: data.at(-1)?.id ?? null };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function requestedModel(body: Record<string, unknown>): string {
|
|
68
|
+
const model = body.model;
|
|
69
|
+
if (typeof model !== 'string' || model === '') throw new GatewayError(400, 'invalid_request_error', 'model is required');
|
|
70
|
+
return model;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const standsInFor = (req: IncomingMessage): boolean => {
|
|
74
|
+
const metroKey = req.headers['x-metro-key'];
|
|
75
|
+
return typeof metroKey === 'string' && metroKey !== '' && req.headers.authorization === `Bearer ${metroKey}`;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
async function toAnthropic(
|
|
79
|
+
req: IncomingMessage,
|
|
80
|
+
res: ServerResponse,
|
|
81
|
+
raw: Buffer,
|
|
82
|
+
body: Record<string, unknown>,
|
|
83
|
+
route: Route,
|
|
84
|
+
deps: GatewayDeps,
|
|
85
|
+
): Promise<void> {
|
|
86
|
+
const explicit = typeof body.model === 'string' && body.model !== route.model;
|
|
87
|
+
const payload = explicit ? Buffer.from(JSON.stringify({ ...body, model: route.model })) : raw;
|
|
88
|
+
const url = `${deps.anthropicBase ?? ANTHROPIC_BASE}${(req.url ?? '').slice(GATEWAY_PREFIX.length)}`;
|
|
89
|
+
const key = deps.config().anthropic.apiKey;
|
|
90
|
+
if (key === '' && standsInFor(req))
|
|
91
|
+
throw new GatewayError(
|
|
92
|
+
403,
|
|
93
|
+
'permission_error',
|
|
94
|
+
'Claude Code on this machine has no Anthropic login of its own; choose Bedrock, OpenRouter or Codex on the Model page, or sign in on the Claude tab',
|
|
95
|
+
);
|
|
96
|
+
const watch = watchUpstream(res);
|
|
97
|
+
const upstream = await fetch(url, {
|
|
98
|
+
method: 'POST',
|
|
99
|
+
headers: key === '' ? forwardedHeaders(req) : anthropicHeaders(req, key),
|
|
100
|
+
body: new Uint8Array(payload),
|
|
101
|
+
signal: watch.signal,
|
|
102
|
+
redirect: 'manual',
|
|
103
|
+
});
|
|
104
|
+
await pipeResponse(upstream, res, watch, key === '' ? {} : { ownCredential: true });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function toOpenRouter(
|
|
108
|
+
req: IncomingMessage,
|
|
109
|
+
res: ServerResponse,
|
|
110
|
+
body: Record<string, unknown>,
|
|
111
|
+
route: Route,
|
|
112
|
+
cfg: ModelConfig,
|
|
113
|
+
deps: GatewayDeps,
|
|
114
|
+
): Promise<void> {
|
|
115
|
+
const reason = notReady(cfg, 'openrouter');
|
|
116
|
+
if (reason !== null) throw new GatewayError(400, 'invalid_request_error', reason);
|
|
117
|
+
const headers: Record<string, string> = {
|
|
118
|
+
'content-type': 'application/json',
|
|
119
|
+
accept: req.headers.accept ?? 'application/json',
|
|
120
|
+
authorization: `Bearer ${cfg.openrouter.apiKey}`,
|
|
121
|
+
'anthropic-version': typeof req.headers['anthropic-version'] === 'string' ? req.headers['anthropic-version'] : '2023-06-01',
|
|
122
|
+
'http-referer': 'https://metro.box',
|
|
123
|
+
'x-title': 'metro',
|
|
124
|
+
};
|
|
125
|
+
const beta = req.headers['anthropic-beta'];
|
|
126
|
+
if (typeof beta === 'string' && beta !== '') headers['anthropic-beta'] = beta;
|
|
127
|
+
const watch = watchUpstream(res);
|
|
128
|
+
const upstream = await fetch(`${deps.openrouterBase ?? OPENROUTER_BASE}${MESSAGES}`, {
|
|
129
|
+
method: 'POST',
|
|
130
|
+
headers,
|
|
131
|
+
body: JSON.stringify({ ...body, model: route.model }),
|
|
132
|
+
signal: watch.signal,
|
|
133
|
+
redirect: 'manual',
|
|
134
|
+
});
|
|
135
|
+
await pipeResponse(upstream, res, watch, { keepalive: true, ownCredential: true });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function dispatch(req: IncomingMessage, res: ServerResponse, path: string, deps: GatewayDeps): Promise<void> {
|
|
139
|
+
const cfg = deps.config();
|
|
140
|
+
const raw = await readBody(req);
|
|
141
|
+
const body = parseJson(raw);
|
|
142
|
+
const route = resolveRoute(requestedModel(body), cfg);
|
|
143
|
+
log.info({ route: routeLabel(route), path }, 'gateway: routing');
|
|
144
|
+
if (path === MESSAGES) noteServed({ provider: route.provider, model: route.model, at: new Date().toISOString() });
|
|
145
|
+
if (route.provider === 'bedrock') {
|
|
146
|
+
assertBedrockReady(cfg.bedrock);
|
|
147
|
+
const up = { settings: cfg.bedrock, base: deps.bedrockBase ?? bedrockBase(cfg.bedrock.region), learned, watch: watchUpstream(res) };
|
|
148
|
+
if (path === COUNT) await bedrockCount(req, res, body, route.model, up);
|
|
149
|
+
else await bedrockMessages(req, res, body, route.model, up);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
if (route.provider === 'openrouter') {
|
|
153
|
+
if (path === COUNT) throw new GatewayError(404, 'not_found_error', 'OpenRouter does not count tokens');
|
|
154
|
+
await toOpenRouter(req, res, body, route, cfg, deps);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (route.provider === 'codex') {
|
|
158
|
+
if (path === COUNT) {
|
|
159
|
+
codexCount(res, body);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
const codexDeps: CodexDeps = { save: saveCodexTokens, ...deps.codex };
|
|
163
|
+
await codexMessages(req, res, body, route.model, cfg, codexDeps, codexState, watchUpstream(res));
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
await toAnthropic(req, res, raw, body, route, deps);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function failed(res: ServerResponse, err: unknown): void {
|
|
170
|
+
if (res.headersSent) {
|
|
171
|
+
res.end();
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
if (err instanceof GatewayError) {
|
|
175
|
+
sendError(res, err.status, err.kind, err.message);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
log.warn({ err: errMsg(err) }, 'gateway: request failed');
|
|
179
|
+
sendError(res, 502, 'api_error', `metro gateway: ${errMsg(err)}`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function answer(req: IncomingMessage, res: ServerResponse, path: string, deps: GatewayDeps): void {
|
|
183
|
+
if (req.method === 'GET' && path === MODELS) {
|
|
184
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
185
|
+
res.end(JSON.stringify(modelsBody(deps.config())));
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (req.method === 'POST' && (path === MESSAGES || path === COUNT)) {
|
|
189
|
+
dispatch(req, res, path, deps).catch((err: unknown) => {
|
|
190
|
+
failed(res, err);
|
|
191
|
+
});
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
sendError(res, 404, 'not_found_error', 'not found');
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function handleGatewayRequest(req: IncomingMessage, res: ServerResponse, deps: GatewayDeps): boolean {
|
|
198
|
+
const full = req.url ?? '';
|
|
199
|
+
if (full !== GATEWAY_PREFIX && !full.startsWith(`${GATEWAY_PREFIX}/`)) return false;
|
|
200
|
+
const path = (full.split('?')[0] ?? '').slice(GATEWAY_PREFIX.length);
|
|
201
|
+
if (req.method === 'HEAD' && path === HELLO) {
|
|
202
|
+
res.writeHead(200).end();
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
205
|
+
if ((deps.identify ?? defaultIdentify)(keyOf(req))) answer(req, res, path, deps);
|
|
206
|
+
else sendError(res, 401, 'authentication_error', 'metro gateway: no agent key (x-metro-key); start Claude Code with metro claude');
|
|
207
|
+
return true;
|
|
208
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
import { ApiError } from '@metro-labs/http/api-error';
|
|
3
|
+
import { apiFailure, apiSession, cors, readJsonBody, sendJson } from '@metro-labs/http/api-http';
|
|
4
|
+
import { isRecord } from '@metro-labs/core/is-record';
|
|
5
|
+
import { log } from '@metro-labs/core/log';
|
|
6
|
+
import { beginLogin, CodexAuthError, finishLogin, readCodexCliAuth } from './codex-auth.js';
|
|
7
|
+
import { beginDeviceLogin, pollDeviceLogin } from './codex-device.js';
|
|
8
|
+
import { codexModels, currentTokens, freshCodexState } from './codex.js';
|
|
9
|
+
import { openrouterModels } from './openrouter.js';
|
|
10
|
+
import { lastServed } from './served.js';
|
|
11
|
+
import type { CodexTokens } from './codex-auth.js';
|
|
12
|
+
import { GatewayError } from './forward.js';
|
|
13
|
+
import {
|
|
14
|
+
applyModelUpdate,
|
|
15
|
+
ModelConfigError,
|
|
16
|
+
publicModelConfig,
|
|
17
|
+
readModelConfig,
|
|
18
|
+
setCodexAuth,
|
|
19
|
+
writeModelConfig,
|
|
20
|
+
type ModelConfig,
|
|
21
|
+
} from './model-config.js';
|
|
22
|
+
|
|
23
|
+
const PATH = '/api/model';
|
|
24
|
+
const CODEX = '/api/model/codex/';
|
|
25
|
+
const OPENROUTER = '/api/model/openrouter/';
|
|
26
|
+
const BODY_MAX = 16 * 1024;
|
|
27
|
+
const DEVICE_PREFIX = 'device/';
|
|
28
|
+
const DEVICE_ID_RE = /^[A-Za-z0-9_-]{16,64}$/;
|
|
29
|
+
|
|
30
|
+
export interface ModelApiDeps {
|
|
31
|
+
authorize: (subject: string) => void;
|
|
32
|
+
read?: () => ModelConfig;
|
|
33
|
+
write?: (cfg: ModelConfig) => void;
|
|
34
|
+
issuer?: string;
|
|
35
|
+
fetchImpl?: typeof fetch;
|
|
36
|
+
codexHome?: string;
|
|
37
|
+
codexBase?: string;
|
|
38
|
+
openrouterBase?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface Store {
|
|
42
|
+
read: () => ModelConfig;
|
|
43
|
+
write: (cfg: ModelConfig) => void;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
type Handler = (req: IncomingMessage, deps: ModelApiDeps, store: Store) => Promise<unknown>;
|
|
47
|
+
|
|
48
|
+
interface Route {
|
|
49
|
+
method: 'GET' | 'POST';
|
|
50
|
+
run: Handler;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const settingsBody = (cfg: ModelConfig): Record<string, unknown> => ({ ...publicModelConfig(cfg), lastServed: lastServed() });
|
|
54
|
+
|
|
55
|
+
function asApiError(err: unknown): never {
|
|
56
|
+
if (err instanceof ModelConfigError || err instanceof CodexAuthError) throw new ApiError(err.message, 400);
|
|
57
|
+
if (err instanceof GatewayError) throw new ApiError(err.message, 502);
|
|
58
|
+
throw err;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function update(req: IncomingMessage, store: Store): Promise<unknown> {
|
|
62
|
+
const patch = await readJsonBody(req, BODY_MAX);
|
|
63
|
+
let next: ModelConfig;
|
|
64
|
+
try {
|
|
65
|
+
next = applyModelUpdate(store.read(), patch);
|
|
66
|
+
} catch (err) {
|
|
67
|
+
asApiError(err);
|
|
68
|
+
}
|
|
69
|
+
store.write(next);
|
|
70
|
+
log.info({ provider: next.provider }, 'model-api: route updated');
|
|
71
|
+
return settingsBody(next);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function saveCodex(store: Store, cfg: ModelConfig, note: string): unknown {
|
|
75
|
+
store.write(cfg);
|
|
76
|
+
log.info({ signedIn: cfg.codex.auth !== null, plan: cfg.codex.auth?.plan ?? null }, note);
|
|
77
|
+
return settingsBody(cfg);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const modelApiState = freshCodexState();
|
|
81
|
+
|
|
82
|
+
async function pollDevice(id: string, deps: ModelApiDeps, store: Store): Promise<unknown> {
|
|
83
|
+
const result = await pollDeviceLogin(id, deps.fetchImpl).catch(asApiError);
|
|
84
|
+
if (result.status !== 'done') return result;
|
|
85
|
+
return { status: 'done', settings: saveCodex(store, setCodexAuth(store.read(), result.tokens), 'model-api: Codex connected by device code') };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const CODEX_ROUTES: Record<string, Route> = {
|
|
89
|
+
device: {
|
|
90
|
+
method: 'POST',
|
|
91
|
+
run: async (_req, deps) => {
|
|
92
|
+
const login = await beginDeviceLogin(deps.issuer, deps.fetchImpl).catch(asApiError);
|
|
93
|
+
return { id: login.id, user_code: login.userCode, verify_url: login.verifyUrl, interval: login.interval };
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
login: {
|
|
97
|
+
method: 'POST',
|
|
98
|
+
run: (_req, deps) => Promise.resolve({ url: beginLogin(deps.issuer).url }),
|
|
99
|
+
},
|
|
100
|
+
callback: {
|
|
101
|
+
method: 'POST',
|
|
102
|
+
run: async (req, deps, store) => {
|
|
103
|
+
const body = await readJsonBody(req, BODY_MAX);
|
|
104
|
+
const raw = isRecord(body) && typeof body.url === 'string' ? body.url : '';
|
|
105
|
+
const tokens = await finishLogin(raw, deps.issuer, deps.fetchImpl).catch(asApiError);
|
|
106
|
+
return saveCodex(store, setCodexAuth(store.read(), tokens), 'model-api: Codex connected');
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
logout: {
|
|
110
|
+
method: 'POST',
|
|
111
|
+
run: (_req, _deps, store) => Promise.resolve(saveCodex(store, setCodexAuth(store.read(), null), 'model-api: Codex disconnected')),
|
|
112
|
+
},
|
|
113
|
+
import: {
|
|
114
|
+
method: 'POST',
|
|
115
|
+
run: (_req, deps, store) => {
|
|
116
|
+
let tokens;
|
|
117
|
+
try {
|
|
118
|
+
tokens = readCodexCliAuth(deps.codexHome);
|
|
119
|
+
} catch (err) {
|
|
120
|
+
asApiError(err);
|
|
121
|
+
}
|
|
122
|
+
return Promise.resolve(saveCodex(store, setCodexAuth(store.read(), tokens), 'model-api: Codex CLI login imported'));
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
models: {
|
|
126
|
+
method: 'GET',
|
|
127
|
+
run: async (_req, deps, store) => {
|
|
128
|
+
const cfg = store.read();
|
|
129
|
+
if (cfg.codex.auth === null) throw new ApiError('Codex is not connected: sign in with ChatGPT first', 400);
|
|
130
|
+
const codexDeps = { issuer: deps.issuer, fetchImpl: deps.fetchImpl, base: deps.codexBase, save: (t: CodexTokens) => { store.write(setCodexAuth(store.read(), t)); } };
|
|
131
|
+
const auth = await currentTokens(cfg, codexDeps, modelApiState).catch(asApiError);
|
|
132
|
+
const models = await codexModels(auth, codexDeps).catch(asApiError);
|
|
133
|
+
return { models };
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const OPENROUTER_ROUTES: Record<string, Route> = {
|
|
139
|
+
models: {
|
|
140
|
+
method: 'GET',
|
|
141
|
+
run: async (_req, deps) => ({ models: await openrouterModels(deps.openrouterBase, deps.fetchImpl).catch(asApiError) }),
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const named = (table: Record<string, Route>, name: string, method: string | undefined): Route | number => {
|
|
146
|
+
const route = table[name];
|
|
147
|
+
if (route === undefined) return 404;
|
|
148
|
+
return route.method === method ? route : 405;
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
function settingsRoute(method: string | undefined): Route | number {
|
|
152
|
+
if (method === 'GET') return { method: 'GET', run: (_req, _deps, store) => Promise.resolve(settingsBody(store.read())) };
|
|
153
|
+
if (method === 'PUT') return { method: 'POST', run: (req, _deps, store) => update(req, store) };
|
|
154
|
+
return 405;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function codexRoute(rest: string, method: string | undefined): Route | number {
|
|
158
|
+
if (rest in CODEX_ROUTES) return named(CODEX_ROUTES, rest, method);
|
|
159
|
+
const id = rest.startsWith(DEVICE_PREFIX) ? rest.slice(DEVICE_PREFIX.length) : '';
|
|
160
|
+
if (!DEVICE_ID_RE.test(id)) return 404;
|
|
161
|
+
return method === 'GET' ? { method: 'GET', run: (_req, deps, store) => pollDevice(id, deps, store) } : 405;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const mine = (path: string): boolean => path === PATH || path.startsWith(CODEX) || path.startsWith(OPENROUTER);
|
|
165
|
+
|
|
166
|
+
function routeFor(path: string, method: string | undefined): Route | number {
|
|
167
|
+
if (path === PATH) return settingsRoute(method);
|
|
168
|
+
if (path.startsWith(OPENROUTER)) return named(OPENROUTER_ROUTES, path.slice(OPENROUTER.length), method);
|
|
169
|
+
return codexRoute(path.slice(CODEX.length), method);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function handleModelRequest(req: IncomingMessage, res: ServerResponse, deps: ModelApiDeps): boolean {
|
|
173
|
+
const path = (req.url ?? '').split('?')[0] ?? '';
|
|
174
|
+
if (!mine(path)) return false;
|
|
175
|
+
if (req.method === 'OPTIONS') {
|
|
176
|
+
res.writeHead(204, cors(req)).end();
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
179
|
+
const route = routeFor(path, req.method);
|
|
180
|
+
if (typeof route === 'number') {
|
|
181
|
+
sendJson(req, res, route, { error: route === 404 ? 'not found' : 'method not allowed' });
|
|
182
|
+
return true;
|
|
183
|
+
}
|
|
184
|
+
const store: Store = { read: deps.read ?? readModelConfig, write: deps.write ?? writeModelConfig };
|
|
185
|
+
apiSession(req)
|
|
186
|
+
.then(async (session) => {
|
|
187
|
+
if (!session) throw new ApiError('unauthorized', 401);
|
|
188
|
+
deps.authorize(session.subject);
|
|
189
|
+
sendJson(req, res, 200, await route.run(req, deps, store));
|
|
190
|
+
})
|
|
191
|
+
.catch((err: unknown) => {
|
|
192
|
+
apiFailure(req, res, err, 'model-api');
|
|
193
|
+
});
|
|
194
|
+
return true;
|
|
195
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { readJson, writeSecure } from '@metro-labs/core/secure-fs';
|
|
3
|
+
import { agentsDir } from '../agents/files.js';
|
|
4
|
+
import { isRecord } from '@metro-labs/core/is-record';
|
|
5
|
+
import type { CodexTokens } from './codex-auth.js';
|
|
6
|
+
|
|
7
|
+
export const PROVIDERS = ['anthropic', 'bedrock', 'openrouter', 'codex'] as const;
|
|
8
|
+
export type Provider = (typeof PROVIDERS)[number];
|
|
9
|
+
|
|
10
|
+
export interface AnthropicSettings {
|
|
11
|
+
apiKey: string;
|
|
12
|
+
model: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface BedrockSettings {
|
|
16
|
+
region: string;
|
|
17
|
+
apiKey: string;
|
|
18
|
+
model: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface OpenRouterSettings {
|
|
22
|
+
apiKey: string;
|
|
23
|
+
model: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface CodexSettings {
|
|
27
|
+
model: string;
|
|
28
|
+
auth: CodexTokens | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ModelConfig {
|
|
32
|
+
version: 1;
|
|
33
|
+
provider: Provider;
|
|
34
|
+
anthropic: AnthropicSettings;
|
|
35
|
+
bedrock: BedrockSettings;
|
|
36
|
+
openrouter: OpenRouterSettings;
|
|
37
|
+
codex: CodexSettings;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface Route {
|
|
41
|
+
provider: Provider;
|
|
42
|
+
model: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export class ModelConfigError extends Error {}
|
|
46
|
+
|
|
47
|
+
export const MODEL_FILE = 'model.json';
|
|
48
|
+
const MAX_FIELD = 512;
|
|
49
|
+
const PREFIX_RE = /^(anthropic|bedrock|openrouter|codex):(.+)$/;
|
|
50
|
+
const SMALL_RE = /haiku/i;
|
|
51
|
+
|
|
52
|
+
const empty = (): ModelConfig => ({
|
|
53
|
+
version: 1,
|
|
54
|
+
provider: 'anthropic',
|
|
55
|
+
anthropic: { apiKey: '', model: '' },
|
|
56
|
+
bedrock: { region: '', apiKey: '', model: '' },
|
|
57
|
+
openrouter: { apiKey: '', model: '' },
|
|
58
|
+
codex: { model: '', auth: null },
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const isProvider = (value: unknown): value is Provider =>
|
|
62
|
+
typeof value === 'string' && (PROVIDERS as readonly string[]).includes(value);
|
|
63
|
+
|
|
64
|
+
const text = (value: unknown): string => (typeof value === 'string' ? value.trim() : '');
|
|
65
|
+
const maybe = (value: unknown): string | null => (typeof value === 'string' && value !== '' ? value : null);
|
|
66
|
+
|
|
67
|
+
function tokensFromDisk(raw: unknown): CodexTokens | null {
|
|
68
|
+
if (!isRecord(raw)) return null;
|
|
69
|
+
const accessToken = text(raw.accessToken);
|
|
70
|
+
const refreshToken = text(raw.refreshToken);
|
|
71
|
+
const accountId = text(raw.accountId);
|
|
72
|
+
if (accessToken === '' || refreshToken === '' || accountId === '') return null;
|
|
73
|
+
return {
|
|
74
|
+
accessToken,
|
|
75
|
+
refreshToken,
|
|
76
|
+
idToken: text(raw.idToken),
|
|
77
|
+
accountId,
|
|
78
|
+
email: maybe(raw.email),
|
|
79
|
+
plan: maybe(raw.plan),
|
|
80
|
+
savedAt: text(raw.savedAt) === '' ? new Date(0).toISOString() : text(raw.savedAt),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function fromDisk(raw: unknown): ModelConfig {
|
|
85
|
+
const base = empty();
|
|
86
|
+
if (!isRecord(raw)) return base;
|
|
87
|
+
const anthropic = isRecord(raw.anthropic) ? raw.anthropic : {};
|
|
88
|
+
const bedrock = isRecord(raw.bedrock) ? raw.bedrock : {};
|
|
89
|
+
const openrouter = isRecord(raw.openrouter) ? raw.openrouter : {};
|
|
90
|
+
const codex = isRecord(raw.codex) ? raw.codex : {};
|
|
91
|
+
return {
|
|
92
|
+
version: 1,
|
|
93
|
+
provider: isProvider(raw.provider) ? raw.provider : 'anthropic',
|
|
94
|
+
anthropic: { apiKey: text(anthropic.apiKey), model: text(anthropic.model) },
|
|
95
|
+
bedrock: { region: text(bedrock.region), apiKey: text(bedrock.apiKey), model: text(bedrock.model) },
|
|
96
|
+
openrouter: { apiKey: text(openrouter.apiKey), model: text(openrouter.model) },
|
|
97
|
+
codex: { model: text(codex.model), auth: tokensFromDisk(codex.auth) },
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function readModelConfig(dir = agentsDir()): ModelConfig {
|
|
102
|
+
return fromDisk(readJson<unknown>(join(dir, MODEL_FILE), null, { warn: 'model-config: model.json is unreadable, so every request goes to Anthropic until it is fixed' }));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function writeModelConfig(cfg: ModelConfig, dir = agentsDir()): void {
|
|
106
|
+
writeSecure(join(dir, MODEL_FILE), JSON.stringify(cfg, null, 2));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function field(patch: Record<string, unknown>, key: string, current: string, what: string): string {
|
|
110
|
+
if (!(key in patch)) return current;
|
|
111
|
+
const value = patch[key];
|
|
112
|
+
if (typeof value !== 'string') throw new ModelConfigError(`${what} must be a string`);
|
|
113
|
+
if (value.length > MAX_FIELD) throw new ModelConfigError(`${what} is too long`);
|
|
114
|
+
return value.trim();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function applyModelUpdate(cfg: ModelConfig, patch: unknown): ModelConfig {
|
|
118
|
+
if (!isRecord(patch)) throw new ModelConfigError('body must be a JSON object');
|
|
119
|
+
const provider = 'provider' in patch ? patch.provider : cfg.provider;
|
|
120
|
+
if (!isProvider(provider)) throw new ModelConfigError(`provider must be one of ${PROVIDERS.join(', ')}`);
|
|
121
|
+
const anthropic = isRecord(patch.anthropic) ? patch.anthropic : {};
|
|
122
|
+
const bedrock = isRecord(patch.bedrock) ? patch.bedrock : {};
|
|
123
|
+
const openrouter = isRecord(patch.openrouter) ? patch.openrouter : {};
|
|
124
|
+
const codex = isRecord(patch.codex) ? patch.codex : {};
|
|
125
|
+
return {
|
|
126
|
+
version: 1,
|
|
127
|
+
provider,
|
|
128
|
+
anthropic: {
|
|
129
|
+
apiKey: field(anthropic, 'apiKey', cfg.anthropic.apiKey, 'Anthropic API key'),
|
|
130
|
+
model: field(anthropic, 'model', cfg.anthropic.model, 'Anthropic model'),
|
|
131
|
+
},
|
|
132
|
+
bedrock: {
|
|
133
|
+
region: field(bedrock, 'region', cfg.bedrock.region, 'Bedrock region'),
|
|
134
|
+
apiKey: field(bedrock, 'apiKey', cfg.bedrock.apiKey, 'Bedrock API key'),
|
|
135
|
+
model: field(bedrock, 'model', cfg.bedrock.model, 'Bedrock model'),
|
|
136
|
+
},
|
|
137
|
+
openrouter: {
|
|
138
|
+
apiKey: field(openrouter, 'apiKey', cfg.openrouter.apiKey, 'OpenRouter API key'),
|
|
139
|
+
model: field(openrouter, 'model', cfg.openrouter.model, 'OpenRouter model'),
|
|
140
|
+
},
|
|
141
|
+
codex: { model: field(codex, 'model', cfg.codex.model, 'Codex model'), auth: cfg.codex.auth },
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export const setCodexAuth = (cfg: ModelConfig, auth: CodexTokens | null): ModelConfig => ({ ...cfg, codex: { ...cfg.codex, auth } });
|
|
146
|
+
|
|
147
|
+
const CHECKS: Record<Provider, [(cfg: ModelConfig) => boolean, string][]> = {
|
|
148
|
+
anthropic: [],
|
|
149
|
+
bedrock: [
|
|
150
|
+
[(cfg) => cfg.bedrock.apiKey === '', 'Bedrock needs an API key: add it on the Model page.'],
|
|
151
|
+
[(cfg) => cfg.bedrock.region === '', 'Bedrock needs a region: add it on the Model page.'],
|
|
152
|
+
],
|
|
153
|
+
openrouter: [
|
|
154
|
+
[(cfg) => cfg.openrouter.apiKey === '', 'OpenRouter needs an API key: add it on the Model page.'],
|
|
155
|
+
[(cfg) => cfg.openrouter.model === '', 'OpenRouter needs a model id: choose one on the Model page.'],
|
|
156
|
+
],
|
|
157
|
+
codex: [
|
|
158
|
+
[(cfg) => cfg.codex.auth === null, 'Codex is not connected: sign in with ChatGPT on the Model page.'],
|
|
159
|
+
[(cfg) => cfg.codex.model === '', 'Codex needs a model id: choose one on the Model page.'],
|
|
160
|
+
],
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
export function notReady(cfg: ModelConfig, provider: Provider = cfg.provider): string | null {
|
|
164
|
+
return CHECKS[provider].find(([missing]) => missing(cfg))?.[1] ?? null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function publicModelConfig(cfg: ModelConfig): Record<string, unknown> {
|
|
168
|
+
const auth = cfg.codex.auth;
|
|
169
|
+
return {
|
|
170
|
+
provider: cfg.provider,
|
|
171
|
+
ready: notReady(cfg) === null,
|
|
172
|
+
reason: notReady(cfg),
|
|
173
|
+
anthropic: { model: cfg.anthropic.model, hasKey: cfg.anthropic.apiKey !== '' },
|
|
174
|
+
bedrock: { region: cfg.bedrock.region, model: cfg.bedrock.model, hasKey: cfg.bedrock.apiKey !== '' },
|
|
175
|
+
openrouter: { model: cfg.openrouter.model, hasKey: cfg.openrouter.apiKey !== '' },
|
|
176
|
+
codex: { model: cfg.codex.model, signedIn: auth !== null, account: auth?.email ?? null, plan: auth?.plan ?? null },
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export const isSmallModel = (requested: string): boolean => SMALL_RE.test(requested);
|
|
181
|
+
|
|
182
|
+
function defaultModelFor(provider: Provider, requested: string, cfg: ModelConfig): string {
|
|
183
|
+
if (provider === 'openrouter') return requested.includes('/') ? requested : cfg.openrouter.model;
|
|
184
|
+
if (provider === 'bedrock') return cfg.bedrock.model === '' ? requested : cfg.bedrock.model;
|
|
185
|
+
if (provider === 'codex') return requested.startsWith('gpt-') ? requested : cfg.codex.model;
|
|
186
|
+
return cfg.anthropic.model === '' || isSmallModel(requested) ? requested : cfg.anthropic.model;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function resolveRoute(requested: string, cfg: ModelConfig): Route {
|
|
190
|
+
const explicit = PREFIX_RE.exec(requested);
|
|
191
|
+
if (explicit !== null && isProvider(explicit[1]))
|
|
192
|
+
return { provider: explicit[1], model: (explicit[2] ?? '').trim() };
|
|
193
|
+
return { provider: cfg.provider, model: defaultModelFor(cfg.provider, requested, cfg) };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function routeLabel(route: Route): string {
|
|
197
|
+
return route.provider === 'anthropic' ? route.model : `${route.provider}:${route.model}`;
|
|
198
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { isRecord } from '@metro-labs/core/is-record';
|
|
2
|
+
import { GatewayError } from './forward.js';
|
|
3
|
+
|
|
4
|
+
export const OPENROUTER_BASE = 'https://openrouter.ai/api';
|
|
5
|
+
const MODELS_MAX = 2000;
|
|
6
|
+
|
|
7
|
+
export interface OpenRouterModel {
|
|
8
|
+
id: string;
|
|
9
|
+
name: string;
|
|
10
|
+
prompt: number | null;
|
|
11
|
+
completion: number | null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const str = (value: unknown): string => (typeof value === 'string' ? value : '');
|
|
15
|
+
|
|
16
|
+
function price(raw: unknown): number | null {
|
|
17
|
+
const value = typeof raw === 'string' ? Number(raw) : typeof raw === 'number' ? raw : Number.NaN;
|
|
18
|
+
return Number.isFinite(value) && value >= 0 ? value : null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function modelOf(entry: unknown): OpenRouterModel | null {
|
|
22
|
+
if (!isRecord(entry)) return null;
|
|
23
|
+
const id = str(entry.id);
|
|
24
|
+
if (id === '') return null;
|
|
25
|
+
const pricing = isRecord(entry.pricing) ? entry.pricing : {};
|
|
26
|
+
return { id, name: str(entry.name) || id, prompt: price(pricing.prompt), completion: price(pricing.completion) };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function openrouterModels(base = OPENROUTER_BASE, fetchImpl: typeof fetch = fetch): Promise<OpenRouterModel[]> {
|
|
30
|
+
const res = await fetchImpl(`${base}/v1/models`, { headers: { accept: 'application/json' }, redirect: 'manual' });
|
|
31
|
+
if (!res.ok) throw new GatewayError(res.status, 'api_error', `OpenRouter would not list its models (${String(res.status)})`);
|
|
32
|
+
const body: unknown = await res.json();
|
|
33
|
+
const data = typeof body === 'object' && body !== null ? (body as { data?: unknown }).data : undefined;
|
|
34
|
+
if (!Array.isArray(data)) throw new GatewayError(502, 'api_error', 'OpenRouter answered with no model list');
|
|
35
|
+
return data
|
|
36
|
+
.map(modelOf)
|
|
37
|
+
.filter((model): model is OpenRouterModel => model !== null)
|
|
38
|
+
.slice(0, MODELS_MAX)
|
|
39
|
+
.sort((a, b) => a.id.localeCompare(b.id));
|
|
40
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export interface Served {
|
|
2
|
+
provider: string;
|
|
3
|
+
model: string;
|
|
4
|
+
at: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
let last: Served | null = null;
|
|
8
|
+
|
|
9
|
+
export const noteServed = (served: Served): void => {
|
|
10
|
+
last = served;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export const lastServed = (): Served | null => last;
|
|
14
|
+
|
|
15
|
+
export const forgetServed = (): void => {
|
|
16
|
+
last = null;
|
|
17
|
+
};
|