@offerpilot/axiomruntime 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/README.md +185 -0
  2. package/dist/cli/commands/add.js +29 -0
  3. package/dist/cli/commands/context.js +29 -0
  4. package/dist/cli/commands/doctor.js +62 -0
  5. package/dist/cli/commands/edit.js +85 -0
  6. package/dist/cli/commands/help.js +16 -0
  7. package/dist/cli/commands/list.js +25 -0
  8. package/dist/cli/commands/log.js +63 -0
  9. package/dist/cli/commands/memory.js +241 -0
  10. package/dist/cli/commands/report.js +35 -0
  11. package/dist/cli/commands/session.js +74 -0
  12. package/dist/cli/commands/setup.js +86 -0
  13. package/dist/cli/commands/status.js +50 -0
  14. package/dist/cli/commands/telegram.js +701 -0
  15. package/dist/cli/commands/use.js +108 -0
  16. package/dist/cli/commands/version.js +12 -0
  17. package/dist/cli/index.js +276 -0
  18. package/dist/cli/output/table.js +22 -0
  19. package/dist/cli/prompts/prompt.js +37 -0
  20. package/dist/cli/registry.js +16 -0
  21. package/dist/core/config/cache-store.js +193 -0
  22. package/dist/core/config/json-store.js +114 -0
  23. package/dist/core/config/paths.js +85 -0
  24. package/dist/core/config/providers-store.js +89 -0
  25. package/dist/core/config/schema.js +60 -0
  26. package/dist/core/config/session-store.js +30 -0
  27. package/dist/core/config/usage-store.js +18 -0
  28. package/dist/core/context/context-service.js +186 -0
  29. package/dist/core/integrations/integration-state.js +105 -0
  30. package/dist/core/logs/log-service.js +56 -0
  31. package/dist/core/memory/embedding-check.js +121 -0
  32. package/dist/core/memory/memory-config.js +122 -0
  33. package/dist/core/models/model-discovery.js +430 -0
  34. package/dist/core/models/model-filter.js +13 -0
  35. package/dist/core/providers/provider-service.js +212 -0
  36. package/dist/core/reports/report-service.js +166 -0
  37. package/dist/core/runner/command-resolver.js +60 -0
  38. package/dist/core/runner/engine-registry.js +93 -0
  39. package/dist/core/runner/fallback.js +114 -0
  40. package/dist/core/runner/openai-usage-http.js +82 -0
  41. package/dist/core/runner/openai-usage-proxy.js +1 -0
  42. package/dist/core/runner/openai-usage-recording.js +172 -0
  43. package/dist/core/runner/openai-usage-responses.js +469 -0
  44. package/dist/core/runner/openai-usage-server.js +319 -0
  45. package/dist/core/runner/openai-usage-types.js +1 -0
  46. package/dist/core/runner/tool-runner.js +138 -0
  47. package/dist/core/sessions/session-service.js +47 -0
  48. package/dist/core/status/doctor-service.js +391 -0
  49. package/dist/core/status/status-service.js +60 -0
  50. package/dist/core/types.js +1 -0
  51. package/dist/core/usage/pricing.js +113 -0
  52. package/dist/core/usage/usage-service.js +30 -0
  53. package/dist/core/utils/is-record.js +3 -0
  54. package/dist/server/index.js +28 -0
  55. package/dist/server/runtime-server.js +430 -0
  56. package/dist/telegram/bot-registry.js +80 -0
  57. package/dist/telegram/bot.js +128 -0
  58. package/dist/telegram/config.js +235 -0
  59. package/dist/telegram/engine/claude-engine.js +240 -0
  60. package/dist/telegram/engine/codex-engine.js +437 -0
  61. package/dist/telegram/engine/engine-utils.js +67 -0
  62. package/dist/telegram/engine/process-utils.js +132 -0
  63. package/dist/telegram/engine/registry.js +31 -0
  64. package/dist/telegram/engine/types.js +1 -0
  65. package/dist/telegram/handler-registry.js +28 -0
  66. package/dist/telegram/handlers/callback.js +311 -0
  67. package/dist/telegram/handlers/command.js +272 -0
  68. package/dist/telegram/handlers/document.js +108 -0
  69. package/dist/telegram/handlers/memory.js +305 -0
  70. package/dist/telegram/handlers/message.js +701 -0
  71. package/dist/telegram/handlers/provider.js +332 -0
  72. package/dist/telegram/handlers/setup.js +527 -0
  73. package/dist/telegram/handlers/usage.js +124 -0
  74. package/dist/telegram/index.js +93 -0
  75. package/dist/telegram/interaction/approval.js +108 -0
  76. package/dist/telegram/interaction/command-menu.js +253 -0
  77. package/dist/telegram/interaction/formatter.js +487 -0
  78. package/dist/telegram/interaction/keyboards.js +145 -0
  79. package/dist/telegram/interaction/progress-reporter.js +160 -0
  80. package/dist/telegram/interaction/prompt-middleware.js +168 -0
  81. package/dist/telegram/interaction/result-store.js +41 -0
  82. package/dist/telegram/interaction/token-budget.js +21 -0
  83. package/dist/telegram/interaction/tool-name.js +41 -0
  84. package/dist/telegram/lifecycle-registry.js +47 -0
  85. package/dist/telegram/log.js +46 -0
  86. package/dist/telegram/memory/memory-inject.js +52 -0
  87. package/dist/telegram/memory/memory-service.js +413 -0
  88. package/dist/telegram/memory/memory-store.js +216 -0
  89. package/dist/telegram/memory/types.js +1 -0
  90. package/dist/telegram/network-retry.js +22 -0
  91. package/dist/telegram/network.js +53 -0
  92. package/dist/telegram/session/manager.js +229 -0
  93. package/dist/telegram/session/store.js +363 -0
  94. package/dist/telegram/session/types.js +1 -0
  95. package/dist/telegram/supervisor.js +57 -0
  96. package/dist/telegram/templates/messages.js +1 -0
  97. package/docs/README.md +98 -0
  98. package/docs/USAGE.html +853 -0
  99. package/package.json +57 -0
@@ -0,0 +1,166 @@
1
+ import fs from "node:fs/promises";
2
+ import { readCache } from "../config/cache-store.js";
3
+ import { readProviders } from "../config/providers-store.js";
4
+ import { getReportPath, getReportsDir } from "../config/paths.js";
5
+ import { readUsage } from "../config/usage-store.js";
6
+ import { generateProjectContext } from "../context/context-service.js";
7
+ import { writeLog } from "../logs/log-service.js";
8
+ import { getSession } from "../sessions/session-service.js";
9
+ export async function generateSessionReport(sessionId) {
10
+ const session = await getSession(sessionId);
11
+ if (!session) {
12
+ throw new Error(`Session not found: ${sessionId}`);
13
+ }
14
+ const [providers, cache, usage] = await Promise.all([
15
+ readProviders(),
16
+ readCache(),
17
+ readUsage()
18
+ ]);
19
+ const provider = providers.find((item) => item.name === session.provider) ?? null;
20
+ const providerCache = cache.providers[session.provider] ?? null;
21
+ const tokenSummary = summarizeSessionUsage(session, usage.events);
22
+ const projectContext = await buildProjectContext(session);
23
+ const content = renderReport({
24
+ session,
25
+ provider,
26
+ providerHealth: providerCache?.status ?? "unknown",
27
+ providerLastCheckedAt: providerCache?.lastCheckedAt ?? null,
28
+ providerModelCount: providerCache?.models.length ?? 0,
29
+ tokenSummary,
30
+ projectContext
31
+ });
32
+ const filePath = getReportPath(session.id);
33
+ await fs.mkdir(getReportsDir(), { recursive: true });
34
+ await fs.writeFile(filePath, content, "utf8");
35
+ await writeLog({
36
+ category: "usage",
37
+ action: "report_generated",
38
+ message: `Generated session report: ${filePath}`,
39
+ metadata: { sessionId: session.id, provider: session.provider, model: session.model }
40
+ });
41
+ return { filePath, content };
42
+ }
43
+ function summarizeSessionUsage(session, events) {
44
+ const startedAt = new Date(session.startedAt).getTime();
45
+ const endedAt = session.endedAt ? new Date(session.endedAt).getTime() : Date.now();
46
+ const matched = events.filter((event) => {
47
+ const createdAt = new Date(event.createdAt).getTime();
48
+ return event.provider === session.provider
49
+ && event.tool === session.tool
50
+ && event.model === session.model
51
+ && createdAt >= startedAt
52
+ && createdAt <= endedAt;
53
+ });
54
+ return matched.reduce((summary, event) => ({
55
+ inputTokens: summary.inputTokens + event.inputTokens,
56
+ outputTokens: summary.outputTokens + event.outputTokens,
57
+ totalTokens: summary.totalTokens + event.totalTokens,
58
+ eventCount: summary.eventCount + 1
59
+ }), { inputTokens: 0, outputTokens: 0, totalTokens: 0, eventCount: 0 });
60
+ }
61
+ async function buildProjectContext(session) {
62
+ try {
63
+ const context = await generateProjectContext(session.projectPath);
64
+ return context.content.trim();
65
+ }
66
+ catch (error) {
67
+ const message = error instanceof Error ? error.message : String(error);
68
+ return [
69
+ "# Project Context",
70
+ "",
71
+ `Project context could not be generated for ${session.projectPath}.`,
72
+ "",
73
+ `Error: ${message}`
74
+ ].join("\n");
75
+ }
76
+ }
77
+ function renderReport(input) {
78
+ const duration = getDuration(input.session);
79
+ const providerLevel = input.provider ? String(input.provider.level) : "(provider removed)";
80
+ const defaultModel = input.provider?.model ?? "(provider removed)";
81
+ return `${[
82
+ "# AI Gateway Session Report",
83
+ "",
84
+ "This report is generated for agent handoff. It intentionally excludes API keys and does not estimate token usage.",
85
+ "",
86
+ "## Session Summary",
87
+ "",
88
+ `- Session ID: ${input.session.id}`,
89
+ `- Tool: ${input.session.tool}`,
90
+ `- Provider: ${input.session.provider}`,
91
+ `- Model: ${input.session.model}`,
92
+ `- Provider priority level: ${providerLevel}`,
93
+ `- Provider default model: ${defaultModel}`,
94
+ `- Permission mode: ${input.session.mode}`,
95
+ `- Project path: ${input.session.projectPath}`,
96
+ `- Status: ${input.session.status}`,
97
+ `- Exit code: ${input.session.exitCode ?? "(running)"}`,
98
+ `- Started at: ${input.session.startedAt}`,
99
+ `- Ended at: ${input.session.endedAt ?? "(running)"}`,
100
+ `- Duration: ${duration}`,
101
+ `- Fallback: ${input.session.fallbackFrom ? `${input.session.fallbackFrom} -> ${input.session.provider}` : "none"}`,
102
+ "",
103
+ "## Token Usage",
104
+ "",
105
+ `- Usage events matched to this session: ${input.tokenSummary.eventCount}`,
106
+ `- Input tokens: ${input.tokenSummary.inputTokens}`,
107
+ `- Output tokens: ${input.tokenSummary.outputTokens}`,
108
+ `- Total tokens: ${input.tokenSummary.totalTokens}`,
109
+ "",
110
+ "Token usage is counted only from structured provider usage events recorded by AI Gateway.",
111
+ "",
112
+ "## Provider Snapshot",
113
+ "",
114
+ `- Health: ${input.providerHealth}`,
115
+ `- Last checked at: ${input.providerLastCheckedAt ?? "(never)"}`,
116
+ `- Discovered model count: ${input.providerModelCount}`,
117
+ "",
118
+ "## Current Session Profile",
119
+ "",
120
+ renderSessionProfile(input.session),
121
+ "",
122
+ "## User Profile",
123
+ "",
124
+ renderUserProfile(),
125
+ "",
126
+ "## Session Context",
127
+ "",
128
+ input.projectContext
129
+ ].join("\n")}\n`;
130
+ }
131
+ function renderSessionProfile(session) {
132
+ const lines = [
133
+ `- This session launched ${session.tool} through AI Gateway with provider ${session.provider}.`,
134
+ `- The selected model was ${session.model}.`,
135
+ `- The working project was ${session.projectPath}.`
136
+ ];
137
+ if (session.fallbackFrom) {
138
+ lines.push(`- The session used fallback from ${session.fallbackFrom} to ${session.provider}.`);
139
+ }
140
+ if (session.status === "running") {
141
+ lines.push("- The session was still marked as running when the report was generated.");
142
+ }
143
+ return lines.join("\n");
144
+ }
145
+ function renderUserProfile() {
146
+ return [
147
+ "- User-facing documentation should be concise and written in Chinese.",
148
+ "- Agent-facing project/context documents can be written in English.",
149
+ "- The user prefers practical interactive workflows that reduce command memorization.",
150
+ "- Documentation should be updated whenever CLI behavior, configuration, or architecture changes.",
151
+ "- Provider secrets must be masked in logs, terminal output, and reports."
152
+ ].join("\n");
153
+ }
154
+ function getDuration(session) {
155
+ const startedAt = new Date(session.startedAt).getTime();
156
+ const endedAt = session.endedAt ? new Date(session.endedAt).getTime() : Date.now();
157
+ if (!Number.isFinite(startedAt) || !Number.isFinite(endedAt) || endedAt < startedAt) {
158
+ return "(unknown)";
159
+ }
160
+ const seconds = Math.round((endedAt - startedAt) / 1000);
161
+ const minutes = Math.floor(seconds / 60);
162
+ const remainingSeconds = seconds % 60;
163
+ if (minutes <= 0)
164
+ return `${remainingSeconds}s`;
165
+ return `${minutes}m ${remainingSeconds}s`;
166
+ }
@@ -0,0 +1,60 @@
1
+ import fs from "node:fs";
2
+ import { spawnSync } from "node:child_process";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ export function resolveToolCommand(tool) {
6
+ return resolveToolCommandWithOptions(tool);
7
+ }
8
+ export function resolveToolCommandWithOptions(tool, options = {}) {
9
+ const command = tool === "claude" ? "claude" : "codex";
10
+ const runner = options.runner ?? isRunnableCommand;
11
+ const fromPath = (options.pathResolver ?? resolveFromPath)(command);
12
+ if (fromPath && runner(fromPath))
13
+ return fromPath;
14
+ for (const candidate of options.candidates ?? getKnownCandidates(tool)) {
15
+ if (runner(candidate)) {
16
+ return candidate;
17
+ }
18
+ }
19
+ return null;
20
+ }
21
+ export function getKnownCandidates(tool) {
22
+ if (tool === "claude") {
23
+ return [
24
+ path.join(os.homedir(), ".local/bin/claude"),
25
+ "/opt/homebrew/bin/claude",
26
+ "/usr/local/bin/claude"
27
+ ];
28
+ }
29
+ return [
30
+ "/Applications/Codex.app/Contents/Resources/codex",
31
+ "/opt/homebrew/bin/codex",
32
+ "/usr/local/bin/codex"
33
+ ];
34
+ }
35
+ function resolveFromPath(command) {
36
+ const result = spawnSync("which", [command], { encoding: "utf8" });
37
+ if (result.status === 0) {
38
+ return result.stdout.trim();
39
+ }
40
+ return null;
41
+ }
42
+ function isExecutable(filePath) {
43
+ try {
44
+ fs.accessSync(filePath, fs.constants.X_OK);
45
+ return true;
46
+ }
47
+ catch {
48
+ return false;
49
+ }
50
+ }
51
+ export function isRunnableCommand(command) {
52
+ if (!isExecutable(command))
53
+ return false;
54
+ const result = spawnSync(command, ["--version"], {
55
+ encoding: "utf8",
56
+ timeout: 5000,
57
+ stdio: ["ignore", "pipe", "pipe"]
58
+ });
59
+ return result.status === 0;
60
+ }
@@ -0,0 +1,93 @@
1
+ const runnerEngines = new Map();
2
+ export function registerRunnerEngine(descriptor) {
3
+ runnerEngines.set(descriptor.name, descriptor);
4
+ }
5
+ export function getRunnerEngine(tool) {
6
+ const descriptor = runnerEngines.get(tool);
7
+ if (!descriptor) {
8
+ throw new Error(`Unknown runner engine: ${tool}`);
9
+ }
10
+ return descriptor;
11
+ }
12
+ export function listRunnerEngines() {
13
+ return [...runnerEngines.values()];
14
+ }
15
+ registerRunnerEngine({
16
+ name: "claude",
17
+ requiresSettingsFile: true,
18
+ authInjection: "temporary Claude settings env + process env",
19
+ normalizeBaseUrl: (baseUrl) => baseUrl.replace(/\/+$/, "").replace(/\/v1$/i, ""),
20
+ buildEnv: (provider, model, baseEnv) => {
21
+ const baseUrl = getRunnerEngine("claude").normalizeBaseUrl(provider.baseUrl);
22
+ return {
23
+ ...baseEnv,
24
+ AI_GATEWAY_PROVIDER: provider.name,
25
+ AI_GATEWAY_MODEL: model,
26
+ AI_GATEWAY_MODE: provider.mode,
27
+ AI_GATEWAY_BASE_URL: baseUrl,
28
+ ANTHROPIC_API_KEY: provider.apiKey,
29
+ ANTHROPIC_BASE_URL: baseUrl,
30
+ ANTHROPIC_MODEL: model
31
+ };
32
+ },
33
+ buildArgs: ({ provider, model, settingsPath }) => {
34
+ const args = ["--model", model];
35
+ if (settingsPath) {
36
+ args.push("--settings", settingsPath);
37
+ }
38
+ if (provider.mode === "auto") {
39
+ args.push("--permission-mode", "bypassPermissions");
40
+ args.push("--dangerously-skip-permissions");
41
+ }
42
+ return args;
43
+ }
44
+ });
45
+ registerRunnerEngine({
46
+ name: "codex",
47
+ authInjection: "process env + process-scoped Codex provider override",
48
+ normalizeBaseUrl: (baseUrl) => {
49
+ const normalized = baseUrl.replace(/\/+$/, "");
50
+ return normalized.endsWith("/v1") ? normalized : `${normalized}/v1`;
51
+ },
52
+ buildEnv: (provider, model, baseEnv) => {
53
+ const baseUrl = getRunnerEngine("codex").normalizeBaseUrl(provider.baseUrl);
54
+ return {
55
+ ...baseEnv,
56
+ AI_GATEWAY_PROVIDER: provider.name,
57
+ AI_GATEWAY_MODEL: model,
58
+ AI_GATEWAY_MODE: provider.mode,
59
+ AI_GATEWAY_BASE_URL: baseUrl,
60
+ OPENAI_API_KEY: provider.apiKey,
61
+ OPENAI_BASE_URL: baseUrl,
62
+ OPENAI_MODEL: model,
63
+ CODEX_MODEL: model
64
+ };
65
+ },
66
+ buildArgs: ({ provider, model }) => {
67
+ const providerId = "ai_gateway_selected";
68
+ const baseUrl = getRunnerEngine("codex").normalizeBaseUrl(provider.baseUrl);
69
+ const args = [
70
+ "-c",
71
+ `model_provider=${tomlString(providerId)}`,
72
+ "-c",
73
+ `model_providers.${providerId}.name=${tomlString(`AI Gateway (${provider.name})`)}`,
74
+ "-c",
75
+ `model_providers.${providerId}.base_url=${tomlString(baseUrl)}`,
76
+ "-c",
77
+ `model_providers.${providerId}.env_key=${tomlString("OPENAI_API_KEY")}`,
78
+ "-c",
79
+ `model_providers.${providerId}.requires_openai_auth=true`,
80
+ "-c",
81
+ `model_providers.${providerId}.wire_api=${tomlString("responses")}`,
82
+ "--model",
83
+ model
84
+ ];
85
+ if (provider.mode === "auto") {
86
+ args.push("--dangerously-bypass-approvals-and-sandbox");
87
+ }
88
+ return args;
89
+ }
90
+ });
91
+ function tomlString(value) {
92
+ return JSON.stringify(value);
93
+ }
@@ -0,0 +1,114 @@
1
+ import { isProviderCircuitOpen, readCache } from "../config/cache-store.js";
2
+ import { readProviders } from "../config/providers-store.js";
3
+ import { writeLog } from "../logs/log-service.js";
4
+ import { isModelCompatible, pickCompatibleModel } from "../models/model-filter.js";
5
+ export async function resolveProviderForTool(tool, providerName, modelName) {
6
+ const candidates = await resolveProviderCandidatesForTool(tool, providerName, modelName);
7
+ const resolved = candidates[0];
8
+ if (!resolved) {
9
+ throw new Error("No usable provider/model found. Run `ai status` or `ai doctor`.");
10
+ }
11
+ await writeLog({
12
+ category: "strategy",
13
+ action: "provider_resolved",
14
+ message: `Resolved provider ${resolved.provider.name} with model ${resolved.model}.`,
15
+ metadata: { provider: resolved.provider.name, tool, model: resolved.model, requestedProvider: providerName ?? null, requestedModel: modelName ?? null }
16
+ });
17
+ return resolved;
18
+ }
19
+ export async function resolveExactProviderForTool(tool, providerName, modelName) {
20
+ const providers = await readProviders();
21
+ const provider = providers.find((candidate) => candidate.name === providerName);
22
+ if (!provider) {
23
+ throw new Error(`Provider not found: ${providerName}`);
24
+ }
25
+ const cache = await readCache();
26
+ const models = cache.providers[provider.name]?.models ?? [];
27
+ const model = resolveModel(tool, provider.model, models, modelName);
28
+ if (!model) {
29
+ throw new Error(`Provider ${providerName} does not offer a compatible ${tool} model.`);
30
+ }
31
+ await writeLog({
32
+ category: "strategy",
33
+ action: "provider_resolved_exact",
34
+ message: `Resolved exact provider ${provider.name} with model ${model}.`,
35
+ metadata: { provider: provider.name, tool, model, requestedProvider: providerName, requestedModel: modelName ?? null }
36
+ });
37
+ return { provider, model };
38
+ }
39
+ export async function resolveProviderCandidatesForTool(tool, providerName, modelName) {
40
+ const providers = await readProviders();
41
+ if (!providers.length) {
42
+ throw new Error("No providers configured. Run `ai add` first.");
43
+ }
44
+ const cache = await readCache();
45
+ const candidates = providerName
46
+ ? [...providers.filter((provider) => provider.name === providerName), ...providers.filter((provider) => provider.name !== providerName)]
47
+ : providers;
48
+ if (providerName && candidates[0]?.name !== providerName) {
49
+ throw new Error(`Provider not found: ${providerName}`);
50
+ }
51
+ const resolved = [];
52
+ for (const provider of candidates) {
53
+ const cached = cache.providers[provider.name];
54
+ if (isProviderCircuitOpen(cached)) {
55
+ await writeLog({
56
+ level: "warn",
57
+ category: "strategy",
58
+ action: "fallback_skip_provider",
59
+ message: `Skipped provider ${provider.name} because circuit breaker is open.`,
60
+ metadata: { provider: provider.name, tool, reason: "circuit_open", circuitOpenUntil: cached?.circuitOpenUntil ?? null }
61
+ });
62
+ continue;
63
+ }
64
+ // Runtime-opened circuits become half-open after their cooldown. A stale
65
+ // status=error must not keep a provider disabled forever after the timer.
66
+ if (cached?.status === "error" && !cached.circuitOpenUntil) {
67
+ await writeLog({
68
+ level: "warn",
69
+ category: "strategy",
70
+ action: "fallback_skip_provider",
71
+ message: `Skipped provider ${provider.name} because health is error.`,
72
+ metadata: { provider: provider.name, tool, reason: cached.lastError }
73
+ });
74
+ continue;
75
+ }
76
+ const models = cached?.models ?? [];
77
+ const model = resolveModel(tool, provider.model, models, modelName);
78
+ if (!model) {
79
+ await writeLog({
80
+ level: "warn",
81
+ category: "strategy",
82
+ action: "fallback_skip_provider",
83
+ message: `Skipped provider ${provider.name} because no compatible model is available.`,
84
+ metadata: { provider: provider.name, tool, defaultModel: provider.model, cachedModels: models.length }
85
+ });
86
+ continue;
87
+ }
88
+ resolved.push({
89
+ provider,
90
+ model,
91
+ fallbackFrom: providerName && provider.name !== providerName ? providerName : undefined
92
+ });
93
+ }
94
+ return resolved;
95
+ }
96
+ function resolveModel(tool, defaultModel, cachedModels, requestedModel) {
97
+ if (requestedModel) {
98
+ // Only offer the requested model when the provider's known catalog serves
99
+ // it; otherwise drop this provider from the rotation instead of guaranteeing
100
+ // an upstream model_not_found. An unknown/never-probed catalog is allowed
101
+ // through optimistically so newly added providers still get a chance.
102
+ if (cachedModels.length && !cachedModels.includes(requestedModel)) {
103
+ return null;
104
+ }
105
+ return requestedModel;
106
+ }
107
+ if (defaultModel && isModelCompatible(tool, defaultModel)) {
108
+ return defaultModel;
109
+ }
110
+ if (cachedModels.length) {
111
+ return pickCompatibleModel(tool, "", cachedModels);
112
+ }
113
+ return defaultModel || null;
114
+ }
@@ -0,0 +1,82 @@
1
+ const MAX_USAGE_CAPTURE_BYTES = 8 * 1024 * 1024;
2
+ export const HOP_BY_HOP_HEADERS = new Set([
3
+ "connection",
4
+ "keep-alive",
5
+ "proxy-authenticate",
6
+ "proxy-authorization",
7
+ "te",
8
+ "trailer",
9
+ "transfer-encoding",
10
+ "upgrade"
11
+ ]);
12
+ const RESPONSE_HEADERS_TO_DROP = new Set([
13
+ ...HOP_BY_HOP_HEADERS,
14
+ "content-encoding",
15
+ "content-length"
16
+ ]);
17
+ export function buildUpstreamUrl(upstreamBaseUrl, incomingUrl) {
18
+ const local = new URL(incomingUrl, "http://127.0.0.1");
19
+ let suffix = local.pathname;
20
+ if (suffix === "/v1") {
21
+ suffix = "";
22
+ }
23
+ else if (suffix.startsWith("/v1/")) {
24
+ suffix = suffix.slice(3);
25
+ }
26
+ const upstream = new URL(`${upstreamBaseUrl.replace(/\/+$/, "")}${suffix}`);
27
+ upstream.search = local.search;
28
+ return upstream.toString();
29
+ }
30
+ export function buildUpstreamHeaders(headers, apiKey) {
31
+ const next = {};
32
+ for (const [key, value] of Object.entries(headers)) {
33
+ const normalized = key.toLowerCase();
34
+ if (HOP_BY_HOP_HEADERS.has(normalized) || normalized === "host" || normalized === "content-length")
35
+ continue;
36
+ if (Array.isArray(value)) {
37
+ next[key] = value.join(", ");
38
+ }
39
+ else if (typeof value === "string") {
40
+ next[key] = value;
41
+ }
42
+ }
43
+ next.authorization = `Bearer ${apiKey}`;
44
+ next["accept-encoding"] = "identity";
45
+ return next;
46
+ }
47
+ export function writeResponseHead(res, response) {
48
+ res.statusCode = response.status;
49
+ res.statusMessage = response.statusText;
50
+ for (const [key, value] of response.headers.entries()) {
51
+ if (!RESPONSE_HEADERS_TO_DROP.has(key.toLowerCase())) {
52
+ res.setHeader(key, value);
53
+ }
54
+ }
55
+ }
56
+ export async function streamResponse(response, res) {
57
+ const chunks = [];
58
+ let capturedBytes = 0;
59
+ if (!response.body)
60
+ return Buffer.alloc(0);
61
+ for await (const chunk of response.body) {
62
+ const buffer = Buffer.from(chunk);
63
+ if (capturedBytes < MAX_USAGE_CAPTURE_BYTES) {
64
+ const remaining = MAX_USAGE_CAPTURE_BYTES - capturedBytes;
65
+ chunks.push(buffer.length > remaining ? buffer.subarray(0, remaining) : buffer);
66
+ capturedBytes += Math.min(buffer.length, remaining);
67
+ }
68
+ res.write(buffer);
69
+ }
70
+ return Buffer.concat(chunks);
71
+ }
72
+ export async function readRequestBody(req) {
73
+ const chunks = [];
74
+ for await (const chunk of req) {
75
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
76
+ }
77
+ return Buffer.concat(chunks);
78
+ }
79
+ export function methodMayHaveBody(method) {
80
+ const normalized = (method ?? "GET").toUpperCase();
81
+ return normalized !== "GET" && normalized !== "HEAD";
82
+ }
@@ -0,0 +1 @@
1
+ export { startOpenAiUsageProxy } from "./openai-usage-server.js";
@@ -0,0 +1,172 @@
1
+ import { appendUsageEvent } from "../config/usage-store.js";
2
+ import { writeLog } from "../logs/log-service.js";
3
+ import { extractTokenUsage } from "../models/model-discovery.js";
4
+ import { estimateTokenCostUsdFromConfig } from "../usage/pricing.js";
5
+ import { buildUsageEvent } from "../usage/usage-service.js";
6
+ export async function recordUsageFromResponse(input) {
7
+ const candidate = extractBestUsageCandidate(input.text, input.contentType, input.fallbackModel);
8
+ if (!candidate) {
9
+ await writeLog({
10
+ category: "usage",
11
+ action: "openai_usage_proxy_no_usage",
12
+ message: `Provider response did not include usage for ${input.provider} / ${input.fallbackModel}.`,
13
+ metadata: {
14
+ tool: input.tool,
15
+ provider: input.provider,
16
+ model: input.fallbackModel,
17
+ contentType: input.contentType
18
+ }
19
+ });
20
+ return false;
21
+ }
22
+ const pricedCost = candidate.costUsd ?? await estimateTokenCostUsdFromConfig(input.provider, candidate.model, candidate.usage);
23
+ const record = {
24
+ provider: input.provider,
25
+ model: candidate.model,
26
+ usage: candidate.usage,
27
+ costUsd: pricedCost,
28
+ costSource: candidate.costUsd !== undefined ? "provider" : pricedCost !== undefined ? "pricing" : "unknown"
29
+ };
30
+ input.records.push(record);
31
+ await appendUsageEvent(buildUsageEvent({
32
+ provider: input.provider,
33
+ tool: input.tool,
34
+ model: record.model,
35
+ source: "proxy",
36
+ inputTokens: record.usage.inputTokens,
37
+ outputTokens: record.usage.outputTokens,
38
+ totalTokens: record.usage.totalTokens,
39
+ cacheReadInputTokens: record.usage.cacheReadInputTokens
40
+ })).catch(() => undefined);
41
+ await writeLog({
42
+ category: "usage",
43
+ action: "openai_usage_proxy_recorded",
44
+ message: `Recorded proxied usage for ${record.provider} / ${record.model}.`,
45
+ metadata: {
46
+ tool: input.tool,
47
+ provider: record.provider,
48
+ model: record.model,
49
+ inputTokens: record.usage.inputTokens,
50
+ outputTokens: record.usage.outputTokens,
51
+ totalTokens: record.usage.totalTokens,
52
+ cacheReadInputTokens: record.usage.cacheReadInputTokens,
53
+ costUsd: record.costUsd,
54
+ costSource: record.costSource
55
+ }
56
+ });
57
+ await Promise.resolve(input.onUsage?.(record)).catch(() => undefined);
58
+ return true;
59
+ }
60
+ function extractBestUsageCandidate(text, contentType, fallbackModel) {
61
+ const values = contentType.includes("text/event-stream") ? parseSseValues(text) : parseJsonValues(text);
62
+ let best = null;
63
+ for (const value of values) {
64
+ for (const candidate of collectUsageCandidates(value, fallbackModel)) {
65
+ if (!best || candidate.usage.totalTokens >= best.usage.totalTokens) {
66
+ best = candidate;
67
+ }
68
+ }
69
+ }
70
+ return best;
71
+ }
72
+ function parseJsonValues(text) {
73
+ const trimmed = text.trim();
74
+ if (!trimmed || !/^[\[{]/.test(trimmed))
75
+ return [];
76
+ try {
77
+ return [JSON.parse(trimmed)];
78
+ }
79
+ catch {
80
+ return [];
81
+ }
82
+ }
83
+ function parseSseValues(text) {
84
+ const values = [];
85
+ let data = "";
86
+ for (const line of text.split(/\r?\n/)) {
87
+ if (!line.trim()) {
88
+ pushSseData(values, data);
89
+ data = "";
90
+ continue;
91
+ }
92
+ if (line.startsWith("data:")) {
93
+ data += `${line.slice(5).trimStart()}\n`;
94
+ }
95
+ }
96
+ pushSseData(values, data);
97
+ return values;
98
+ }
99
+ function pushSseData(values, data) {
100
+ const trimmed = data.trim();
101
+ if (!trimmed || trimmed === "[DONE]")
102
+ return;
103
+ try {
104
+ values.push(JSON.parse(trimmed));
105
+ }
106
+ catch {
107
+ // Ignore non-JSON SSE frames.
108
+ }
109
+ }
110
+ function collectUsageCandidates(value, fallbackModel, depth = 0) {
111
+ if (depth > 6 || !value || typeof value !== "object")
112
+ return [];
113
+ const candidates = [];
114
+ const usage = extractTokenUsage(value);
115
+ if (usage) {
116
+ candidates.push({
117
+ model: extractModel(value) ?? fallbackModel,
118
+ usage,
119
+ costUsd: extractCostUsd(value)
120
+ });
121
+ }
122
+ if (Array.isArray(value)) {
123
+ for (const item of value) {
124
+ candidates.push(...collectUsageCandidates(item, fallbackModel, depth + 1));
125
+ }
126
+ return candidates;
127
+ }
128
+ const record = value;
129
+ for (const nested of Object.values(record)) {
130
+ candidates.push(...collectUsageCandidates(nested, extractModel(record) ?? fallbackModel, depth + 1));
131
+ }
132
+ return candidates;
133
+ }
134
+ export function extractRequestModel(body) {
135
+ const text = body.toString("utf8").trim();
136
+ if (!text || !text.startsWith("{"))
137
+ return undefined;
138
+ try {
139
+ return extractModel(JSON.parse(text));
140
+ }
141
+ catch {
142
+ return undefined;
143
+ }
144
+ }
145
+ function extractModel(value) {
146
+ if (!value || typeof value !== "object" || Array.isArray(value))
147
+ return undefined;
148
+ const model = value.model;
149
+ return typeof model === "string" && model.trim() ? model.trim() : undefined;
150
+ }
151
+ function extractCostUsd(value) {
152
+ if (!value || typeof value !== "object" || Array.isArray(value))
153
+ return undefined;
154
+ const record = value;
155
+ for (const key of ["total_cost_usd", "cost_usd", "costUsd", "totalCostUsd", "total_cost", "cost"]) {
156
+ const cost = readCostValue(record[key]);
157
+ if (cost !== undefined)
158
+ return cost;
159
+ }
160
+ return undefined;
161
+ }
162
+ function readCostValue(value) {
163
+ if (typeof value === "number" && Number.isFinite(value) && value >= 0)
164
+ return value;
165
+ if (typeof value === "string" && value.trim()) {
166
+ const normalized = value.trim().replace(/^\$/, "");
167
+ const numeric = Number(normalized);
168
+ if (Number.isFinite(numeric) && numeric >= 0)
169
+ return numeric;
170
+ }
171
+ return undefined;
172
+ }