ada-agent 0.1.0
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/LICENSE +21 -0
- package/README.md +256 -0
- package/bench/README.md +88 -0
- package/bench/swebench.mjs +242 -0
- package/bin/ada-server.mjs +6 -0
- package/bin/ada.mjs +7 -0
- package/docs/agent-loop.svg +66 -0
- package/docs/architecture.md +139 -0
- package/docs/architecture.svg +73 -0
- package/docs/connectors.md +48 -0
- package/docs/integrations.md +59 -0
- package/docs/login-flow.svg +56 -0
- package/docs/orchestration.md +45 -0
- package/package.json +64 -0
- package/skills/accessibility/SKILL.md +23 -0
- package/skills/add-logging/SKILL.md +23 -0
- package/skills/add-metrics/SKILL.md +23 -0
- package/skills/adr/SKILL.md +24 -0
- package/skills/aesthetic-direction/SKILL.md +24 -0
- package/skills/agent-loop/SKILL.md +23 -0
- package/skills/alerting/SKILL.md +23 -0
- package/skills/alpha-compositing/SKILL.md +23 -0
- package/skills/android-compose/SKILL.md +23 -0
- package/skills/angular-module/SKILL.md +23 -0
- package/skills/ansible-playbook/SKILL.md +24 -0
- package/skills/api-docs/SKILL.md +24 -0
- package/skills/app-store-prep/SKILL.md +23 -0
- package/skills/architecture-diagram/SKILL.md +21 -0
- package/skills/architecture-doc/SKILL.md +24 -0
- package/skills/audit-log/SKILL.md +23 -0
- package/skills/authz-review/SKILL.md +23 -0
- package/skills/aws-lambda/SKILL.md +24 -0
- package/skills/bash-script/SKILL.md +23 -0
- package/skills/batch/SKILL.md +23 -0
- package/skills/bisect/SKILL.md +23 -0
- package/skills/bounding-box/SKILL.md +24 -0
- package/skills/branch-cleanup/SKILL.md +23 -0
- package/skills/bundle-analyze/SKILL.md +23 -0
- package/skills/cache/SKILL.md +23 -0
- package/skills/call-graph/SKILL.md +23 -0
- package/skills/canvas-debug/SKILL.md +23 -0
- package/skills/cdn-setup/SKILL.md +23 -0
- package/skills/changelog/SKILL.md +24 -0
- package/skills/cherry-pick/SKILL.md +23 -0
- package/skills/ci-setup/SKILL.md +23 -0
- package/skills/cleanup/SKILL.md +23 -0
- package/skills/cli-tool/SKILL.md +23 -0
- package/skills/cloudformation/SKILL.md +23 -0
- package/skills/code-examples/SKILL.md +24 -0
- package/skills/code-review/SKILL.md +23 -0
- package/skills/color-palette/SKILL.md +24 -0
- package/skills/color-space/SKILL.md +24 -0
- package/skills/comment-why/SKILL.md +23 -0
- package/skills/commit/SKILL.md +26 -0
- package/skills/complexity-audit/SKILL.md +23 -0
- package/skills/component/SKILL.md +23 -0
- package/skills/component-library/SKILL.md +23 -0
- package/skills/connect-github/SKILL.md +20 -0
- package/skills/connect-mcp/SKILL.md +21 -0
- package/skills/connect-postgres/SKILL.md +20 -0
- package/skills/connect-remote/SKILL.md +23 -0
- package/skills/connect-slack/SKILL.md +20 -0
- package/skills/contract-audit/SKILL.md +25 -0
- package/skills/contributing/SKILL.md +23 -0
- package/skills/cpp-raii/SKILL.md +23 -0
- package/skills/cron-job/SKILL.md +23 -0
- package/skills/cv-preprocess/SKILL.md +24 -0
- package/skills/dark-mode/SKILL.md +24 -0
- package/skills/dashboard/SKILL.md +23 -0
- package/skills/dashboard-ui/SKILL.md +23 -0
- package/skills/data-export/SKILL.md +23 -0
- package/skills/data-validation/SKILL.md +23 -0
- package/skills/dataframe/SKILL.md +23 -0
- package/skills/db-index/SKILL.md +24 -0
- package/skills/dead-code/SKILL.md +23 -0
- package/skills/debug/SKILL.md +24 -0
- package/skills/deck-review/SKILL.md +24 -0
- package/skills/dedupe/SKILL.md +23 -0
- package/skills/dedupe-deps/SKILL.md +23 -0
- package/skills/dependency-audit/SKILL.md +23 -0
- package/skills/dependency-update/SKILL.md +23 -0
- package/skills/deploy/SKILL.md +23 -0
- package/skills/design-system/SKILL.md +24 -0
- package/skills/design-tokens/SKILL.md +24 -0
- package/skills/diagram-as-code/SKILL.md +24 -0
- package/skills/diff-explain/SKILL.md +23 -0
- package/skills/django-view/SKILL.md +23 -0
- package/skills/doc-lint/SKILL.md +24 -0
- package/skills/docker-compose/SKILL.md +23 -0
- package/skills/dockerize/SKILL.md +23 -0
- package/skills/docstrings/SKILL.md +23 -0
- package/skills/dotfiles/SKILL.md +23 -0
- package/skills/dpi-scaling/SKILL.md +23 -0
- package/skills/e2e-test/SKILL.md +23 -0
- package/skills/embeddings/SKILL.md +23 -0
- package/skills/empty-states/SKILL.md +23 -0
- package/skills/env-setup/SKILL.md +23 -0
- package/skills/erc20/SKILL.md +24 -0
- package/skills/error-tracking/SKILL.md +23 -0
- package/skills/estimate/SKILL.md +23 -0
- package/skills/etl-pipeline/SKILL.md +24 -0
- package/skills/eval-harness/SKILL.md +23 -0
- package/skills/exif-orientation/SKILL.md +23 -0
- package/skills/explain-code/SKILL.md +23 -0
- package/skills/express-middleware/SKILL.md +23 -0
- package/skills/extract-function/SKILL.md +23 -0
- package/skills/faq/SKILL.md +24 -0
- package/skills/fastapi-endpoint/SKILL.md +23 -0
- package/skills/favicon/SKILL.md +23 -0
- package/skills/feature-engineering/SKILL.md +23 -0
- package/skills/few-shot/SKILL.md +23 -0
- package/skills/find-owner/SKILL.md +23 -0
- package/skills/firmware-driver/SKILL.md +23 -0
- package/skills/fix-flaky-tests/SKILL.md +23 -0
- package/skills/flutter-widget/SKILL.md +23 -0
- package/skills/font-rendering/SKILL.md +23 -0
- package/skills/form-validation/SKILL.md +23 -0
- package/skills/format/SKILL.md +23 -0
- package/skills/game-loop/SKILL.md +23 -0
- package/skills/gas-optimize/SKILL.md +25 -0
- package/skills/gdpr-review/SKILL.md +24 -0
- package/skills/github-actions/SKILL.md +23 -0
- package/skills/glossary/SKILL.md +24 -0
- package/skills/go-idioms/SKILL.md +23 -0
- package/skills/gpu-profile/SKILL.md +23 -0
- package/skills/graphify/SKILL.md +21 -0
- package/skills/graphql-resolver/SKILL.md +23 -0
- package/skills/grpc-service/SKILL.md +23 -0
- package/skills/guardrails/SKILL.md +23 -0
- package/skills/healthcheck/SKILL.md +23 -0
- package/skills/heisenbug/SKILL.md +23 -0
- package/skills/helm-chart/SKILL.md +24 -0
- package/skills/hero-section/SKILL.md +23 -0
- package/skills/html-email/SKILL.md +24 -0
- package/skills/html-form/SKILL.md +23 -0
- package/skills/html-sanitize/SKILL.md +23 -0
- package/skills/html-table/SKILL.md +23 -0
- package/skills/html-to-pdf/SKILL.md +23 -0
- package/skills/http-client/SKILL.md +23 -0
- package/skills/i18n/SKILL.md +23 -0
- package/skills/i2c-spi/SKILL.md +23 -0
- package/skills/image-decode/SKILL.md +24 -0
- package/skills/image-memory/SKILL.md +24 -0
- package/skills/image-perf/SKILL.md +24 -0
- package/skills/image-pipeline/SKILL.md +24 -0
- package/skills/image-upload/SKILL.md +24 -0
- package/skills/infra-cost/SKILL.md +24 -0
- package/skills/input-validation/SKILL.md +23 -0
- package/skills/issue-template/SKILL.md +23 -0
- package/skills/java-streams/SKILL.md +23 -0
- package/skills/k8s-manifest/SKILL.md +23 -0
- package/skills/kotlin-coroutines/SKILL.md +23 -0
- package/skills/landing-page/SKILL.md +24 -0
- package/skills/laravel-controller/SKILL.md +23 -0
- package/skills/lazy-load/SKILL.md +23 -0
- package/skills/license-check/SKILL.md +23 -0
- package/skills/license-header/SKILL.md +23 -0
- package/skills/lint-fix/SKILL.md +23 -0
- package/skills/llm-cost/SKILL.md +23 -0
- package/skills/lockfile-fix/SKILL.md +23 -0
- package/skills/low-power/SKILL.md +23 -0
- package/skills/makefile/SKILL.md +23 -0
- package/skills/man-page/SKILL.md +24 -0
- package/skills/mcp-server/SKILL.md +23 -0
- package/skills/memory-leak/SKILL.md +23 -0
- package/skills/mermaid-diagram/SKILL.md +23 -0
- package/skills/meta-tags/SKILL.md +23 -0
- package/skills/micro-interactions/SKILL.md +23 -0
- package/skills/migration/SKILL.md +23 -0
- package/skills/migration-guide/SKILL.md +24 -0
- package/skills/mkdocs-setup/SKILL.md +24 -0
- package/skills/mobile-permissions/SKILL.md +23 -0
- package/skills/mock-api/SKILL.md +23 -0
- package/skills/modernize/SKILL.md +23 -0
- package/skills/monorepo-setup/SKILL.md +23 -0
- package/skills/motion-design/SKILL.md +23 -0
- package/skills/n-plus-one/SKILL.md +23 -0
- package/skills/naming-review/SKILL.md +23 -0
- package/skills/nextjs-route/SKILL.md +23 -0
- package/skills/nginx-config/SKILL.md +23 -0
- package/skills/ocr-debug/SKILL.md +24 -0
- package/skills/onboard/SKILL.md +23 -0
- package/skills/onboarding-map/SKILL.md +23 -0
- package/skills/open-pr/SKILL.md +24 -0
- package/skills/openapi/SKILL.md +23 -0
- package/skills/opencv-debug/SKILL.md +24 -0
- package/skills/orm-model/SKILL.md +23 -0
- package/skills/owasp-check/SKILL.md +24 -0
- package/skills/page-transitions/SKILL.md +23 -0
- package/skills/pagination/SKILL.md +23 -0
- package/skills/perf-optimize/SKILL.md +23 -0
- package/skills/perf-profile/SKILL.md +23 -0
- package/skills/physics/SKILL.md +23 -0
- package/skills/pitch-deck/SKILL.md +24 -0
- package/skills/pixel-diff/SKILL.md +23 -0
- package/skills/ponytail/SKILL.md +46 -0
- package/skills/postmortem/SKILL.md +24 -0
- package/skills/pptx-deck/SKILL.md +23 -0
- package/skills/pptx-export/SKILL.md +23 -0
- package/skills/pptx-from-markdown/SKILL.md +23 -0
- package/skills/pptx-template/SKILL.md +24 -0
- package/skills/pr-review/SKILL.md +24 -0
- package/skills/precommit/SKILL.md +23 -0
- package/skills/pricing-page/SKILL.md +23 -0
- package/skills/project-overview/SKILL.md +22 -0
- package/skills/prompt-template/SKILL.md +23 -0
- package/skills/property-test/SKILL.md +23 -0
- package/skills/protobuf/SKILL.md +23 -0
- package/skills/py-async/SKILL.md +23 -0
- package/skills/py-typing/SKILL.md +23 -0
- package/skills/query-optimize/SKILL.md +23 -0
- package/skills/rag-pipeline/SKILL.md +23 -0
- package/skills/rails-resource/SKILL.md +23 -0
- package/skills/rate-limit/SKILL.md +23 -0
- package/skills/react-hooks/SKILL.md +23 -0
- package/skills/react-native-screen/SKILL.md +23 -0
- package/skills/react-perf/SKILL.md +23 -0
- package/skills/readme/SKILL.md +24 -0
- package/skills/rebase/SKILL.md +24 -0
- package/skills/refactor/SKILL.md +23 -0
- package/skills/regression-test/SKILL.md +23 -0
- package/skills/release-notes/SKILL.md +24 -0
- package/skills/rename-symbol/SKILL.md +23 -0
- package/skills/repro/SKILL.md +23 -0
- package/skills/resolve-conflicts/SKILL.md +23 -0
- package/skills/responsive/SKILL.md +23 -0
- package/skills/rest-endpoint/SKILL.md +23 -0
- package/skills/retro/SKILL.md +23 -0
- package/skills/rtos-task/SKILL.md +23 -0
- package/skills/runbook/SKILL.md +25 -0
- package/skills/rust-borrow/SKILL.md +23 -0
- package/skills/rust-unsafe-audit/SKILL.md +23 -0
- package/skills/sanitize/SKILL.md +23 -0
- package/skills/schema-design/SKILL.md +23 -0
- package/skills/screenshot-debug/SKILL.md +22 -0
- package/skills/scroll-animation/SKILL.md +23 -0
- package/skills/secret-scan/SKILL.md +23 -0
- package/skills/security-audit/SKILL.md +23 -0
- package/skills/security-review/SKILL.md +23 -0
- package/skills/seed-data/SKILL.md +23 -0
- package/skills/self-review/SKILL.md +23 -0
- package/skills/semantic-html/SKILL.md +23 -0
- package/skills/semver-bump/SKILL.md +24 -0
- package/skills/shader/SKILL.md +23 -0
- package/skills/shader-debug/SKILL.md +23 -0
- package/skills/simplify-conditionals/SKILL.md +23 -0
- package/skills/sitemap/SKILL.md +23 -0
- package/skills/skeleton-loader/SKILL.md +23 -0
- package/skills/slide-charts/SKILL.md +24 -0
- package/skills/slide-outline/SKILL.md +23 -0
- package/skills/snapshot-update/SKILL.md +23 -0
- package/skills/solidity-contract/SKILL.md +25 -0
- package/skills/speaker-notes/SKILL.md +23 -0
- package/skills/spike/SKILL.md +23 -0
- package/skills/split-file/SKILL.md +23 -0
- package/skills/spring-controller/SKILL.md +23 -0
- package/skills/sprite-anim/SKILL.md +23 -0
- package/skills/sql-report/SKILL.md +23 -0
- package/skills/squash/SKILL.md +24 -0
- package/skills/ssl-setup/SKILL.md +23 -0
- package/skills/stacktrace/SKILL.md +23 -0
- package/skills/static-site/SKILL.md +24 -0
- package/skills/structured-logging/SKILL.md +23 -0
- package/skills/svelte-store/SKILL.md +23 -0
- package/skills/swiftui-view/SKILL.md +23 -0
- package/skills/tailwind-theme/SKILL.md +24 -0
- package/skills/tcp-server/SKILL.md +23 -0
- package/skills/tdd/SKILL.md +23 -0
- package/skills/terraform-module/SKILL.md +24 -0
- package/skills/test-coverage/SKILL.md +23 -0
- package/skills/texture-debug/SKILL.md +23 -0
- package/skills/threat-model/SKILL.md +23 -0
- package/skills/thumbnail/SKILL.md +24 -0
- package/skills/todo-scan/SKILL.md +23 -0
- package/skills/tool-definition/SKILL.md +23 -0
- package/skills/trace-flow/SKILL.md +23 -0
- package/skills/tracing/SKILL.md +23 -0
- package/skills/train-model/SKILL.md +24 -0
- package/skills/tree-shake/SKILL.md +23 -0
- package/skills/ts-generics/SKILL.md +23 -0
- package/skills/ts-strict/SKILL.md +23 -0
- package/skills/tui-app/SKILL.md +23 -0
- package/skills/tutorial/SKILL.md +24 -0
- package/skills/type-tighten/SKILL.md +23 -0
- package/skills/typography/SKILL.md +24 -0
- package/skills/ui-bug-repro/SKILL.md +23 -0
- package/skills/ui-polish/SKILL.md +24 -0
- package/skills/ui-review/SKILL.md +24 -0
- package/skills/vendor/SKILL.md +23 -0
- package/skills/visual-diff-ci/SKILL.md +24 -0
- package/skills/visual-regression/SKILL.md +23 -0
- package/skills/vue-composition/SKILL.md +23 -0
- package/skills/web-component/SKILL.md +23 -0
- package/skills/web-fonts/SKILL.md +24 -0
- package/skills/web3-frontend/SKILL.md +25 -0
- package/skills/webgl-debug/SKILL.md +23 -0
- package/skills/webhook/SKILL.md +23 -0
- package/skills/websocket/SKILL.md +23 -0
- package/skills/write-tests/SKILL.md +19 -0
- package/src/client/agent.ts +803 -0
- package/src/client/background.ts +39 -0
- package/src/client/checkpoint.ts +48 -0
- package/src/client/cli.ts +1253 -0
- package/src/client/compaction.ts +86 -0
- package/src/client/extensions.ts +83 -0
- package/src/client/hooks.ts +40 -0
- package/src/client/image.ts +26 -0
- package/src/client/lsp.ts +0 -0
- package/src/client/mcp.ts +276 -0
- package/src/client/models-dev.ts +52 -0
- package/src/client/pkg.ts +41 -0
- package/src/client/platform.ts +94 -0
- package/src/client/prompts.ts +47 -0
- package/src/client/render.ts +138 -0
- package/src/client/session.ts +107 -0
- package/src/client/settings.ts +86 -0
- package/src/client/skill-router.ts +79 -0
- package/src/client/skills.ts +199 -0
- package/src/client/snapshot.ts +56 -0
- package/src/client/telemetry.ts +24 -0
- package/src/client/todos.ts +23 -0
- package/src/client/tools.ts +756 -0
- package/src/client/tui-mode.ts +41 -0
- package/src/client/tui.ts +224 -0
- package/src/sdk/index.ts +36 -0
- package/src/selfcheck.ts +364 -0
- package/src/server/config.ts +58 -0
- package/src/server/credentials.ts +89 -0
- package/src/server/identity.ts +58 -0
- package/src/server/index.ts +113 -0
- package/src/server/oauth.ts +93 -0
- package/src/server/providers/adapter.ts +25 -0
- package/src/server/providers/anthropic.ts +189 -0
- package/src/server/providers/openai-compat.ts +76 -0
- package/src/server/providers/registry.ts +31 -0
- package/src/server/router.ts +29 -0
- package/src/server/sse.ts +20 -0
- package/src/shared/types.ts +20 -0
- package/tsconfig.json +15 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Backend configuration: provider upstreams, keys, client-key auth, port.
|
|
2
|
+
// Everything is env-driven. The backend is the only place provider keys live.
|
|
3
|
+
|
|
4
|
+
import { getCredential } from "./credentials.ts";
|
|
5
|
+
import type { ProviderName } from "../shared/types.ts";
|
|
6
|
+
|
|
7
|
+
export interface ProviderDef {
|
|
8
|
+
baseURL: string; // OpenAI-compatible base (…/v1) — every provider is proxied as-is
|
|
9
|
+
keyEnv: string; // env var holding this provider's key ("" = keyless, e.g. local Ollama)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const PROVIDERS: Record<ProviderName, ProviderDef> = {
|
|
13
|
+
openai: { baseURL: "https://api.openai.com/v1", keyEnv: "OPENAI_API_KEY" },
|
|
14
|
+
anthropic: { baseURL: "https://api.anthropic.com/v1", keyEnv: "ANTHROPIC_API_KEY" },
|
|
15
|
+
google: { baseURL: "https://generativelanguage.googleapis.com/v1beta/openai", keyEnv: "GEMINI_API_KEY" },
|
|
16
|
+
mistral: { baseURL: "https://api.mistral.ai/v1", keyEnv: "MISTRAL_API_KEY" },
|
|
17
|
+
openrouter: { baseURL: "https://openrouter.ai/api/v1", keyEnv: "OPENROUTER_API_KEY" },
|
|
18
|
+
groq: { baseURL: "https://api.groq.com/openai/v1", keyEnv: "GROQ_API_KEY" },
|
|
19
|
+
deepseek: { baseURL: "https://api.deepseek.com", keyEnv: "DEEPSEEK_API_KEY" },
|
|
20
|
+
together: { baseURL: "https://api.together.xyz/v1", keyEnv: "TOGETHER_API_KEY" },
|
|
21
|
+
xai: { baseURL: "https://api.x.ai/v1", keyEnv: "XAI_API_KEY" },
|
|
22
|
+
dashscope: {
|
|
23
|
+
baseURL: process.env.DASHSCOPE_BASE_URL ?? "https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
|
|
24
|
+
keyEnv: "DASHSCOPE_API_KEY",
|
|
25
|
+
},
|
|
26
|
+
// GitHub Copilot — OpenAI-compatible chat endpoint. COPILOT_API_KEY must be a Copilot *bearer*
|
|
27
|
+
// token (exchanged from a GitHub OAuth token at /copilot_internal/v2/token — that exchange is not
|
|
28
|
+
// implemented here; it needs a Copilot subscription). Required headers are added in the adapter.
|
|
29
|
+
copilot: { baseURL: process.env.COPILOT_BASE_URL ?? "https://api.githubcopilot.com", keyEnv: "COPILOT_API_KEY" },
|
|
30
|
+
ollama: { baseURL: process.env.OLLAMA_BASE_URL ?? "http://localhost:11434/v1", keyEnv: "" },
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const PORT = Number(process.env.ADA_PORT) || 8787;
|
|
34
|
+
|
|
35
|
+
/** The ada client keys allowed to use this backend. null = auth disabled (dev mode). */
|
|
36
|
+
export function clientKeys(): string[] | null {
|
|
37
|
+
const v = process.env.ADA_CLIENT_KEYS;
|
|
38
|
+
if (!v) return null;
|
|
39
|
+
return v.split(",").map((s) => s.trim()).filter(Boolean);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The upstream provider key: env var first, then a stored credential (API key or OAuth token). */
|
|
43
|
+
export function providerKey(p: ProviderName): string | undefined {
|
|
44
|
+
const env = PROVIDERS[p].keyEnv;
|
|
45
|
+
if (env && process.env[env]) return process.env[env];
|
|
46
|
+
const cred = getCredential(p);
|
|
47
|
+
if (cred) return cred.type === "oauth" ? cred.access : cred.key;
|
|
48
|
+
return undefined; // keyless provider (Ollama) or unconfigured
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A provider is usable if it's keyless, its key env var is set, or a credential is stored. */
|
|
52
|
+
export function isConfigured(p: ProviderName): boolean {
|
|
53
|
+
return PROVIDERS[p].keyEnv === "" || !!process.env[PROVIDERS[p].keyEnv] || !!getCredential(p);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function configuredProviders(): ProviderName[] {
|
|
57
|
+
return (Object.keys(PROVIDERS) as ProviderName[]).filter(isConfigured);
|
|
58
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Credential store at ~/.ada/credentials.json. Holds API keys and OAuth tokens so the
|
|
2
|
+
// backend can use them as provider keys. Writes are atomic (temp + rename) and guarded by a
|
|
3
|
+
// coarse cross-process lock (an atomically-created lock dir) so a token refresh can't race.
|
|
4
|
+
|
|
5
|
+
import { mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { dirname, join } from "node:path";
|
|
8
|
+
|
|
9
|
+
const FILE = join(homedir(), ".ada", "credentials.json");
|
|
10
|
+
|
|
11
|
+
export interface Credential {
|
|
12
|
+
type: "api_key" | "oauth";
|
|
13
|
+
key?: string; // api_key
|
|
14
|
+
access?: string; // oauth access token
|
|
15
|
+
refresh?: string; // oauth refresh token
|
|
16
|
+
expires?: number; // epoch ms
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
type Store = Record<string, Credential>;
|
|
20
|
+
|
|
21
|
+
function read(): Store {
|
|
22
|
+
try {
|
|
23
|
+
return JSON.parse(readFileSync(FILE, "utf8")) as Store;
|
|
24
|
+
} catch {
|
|
25
|
+
return {};
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function write(s: Store): void {
|
|
30
|
+
mkdirSync(dirname(FILE), { recursive: true });
|
|
31
|
+
const tmp = `${FILE}.${process.pid}.tmp`;
|
|
32
|
+
writeFileSync(tmp, JSON.stringify(s, null, 2), "utf8");
|
|
33
|
+
renameSync(tmp, FILE); // atomic replace
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function withLock<T>(fn: () => T): Promise<T> {
|
|
37
|
+
mkdirSync(dirname(FILE), { recursive: true }); // ensure ~/.ada exists so the lock dir can be created
|
|
38
|
+
const lock = `${FILE}.lock`;
|
|
39
|
+
for (let i = 0; ; i++) {
|
|
40
|
+
try {
|
|
41
|
+
mkdirSync(lock); // mkdir is atomic: succeeds for exactly one holder
|
|
42
|
+
break;
|
|
43
|
+
} catch {
|
|
44
|
+
try {
|
|
45
|
+
if (Date.now() - statSync(lock).mtimeMs > 30_000) {
|
|
46
|
+
rmSync(lock, { recursive: true, force: true }); // break a stale lock from a crashed run
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
} catch {
|
|
50
|
+
/* lock vanished — retry */
|
|
51
|
+
}
|
|
52
|
+
if (i >= 50) throw new Error("could not acquire credential lock");
|
|
53
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
return fn();
|
|
58
|
+
} finally {
|
|
59
|
+
try {
|
|
60
|
+
rmSync(lock, { recursive: true, force: true });
|
|
61
|
+
} catch {
|
|
62
|
+
/* ignore */
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function getCredential(provider: string): Credential | undefined {
|
|
68
|
+
return read()[provider];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function listCredentials(): string[] {
|
|
72
|
+
return Object.keys(read());
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function setCredential(provider: string, cred: Credential): Promise<void> {
|
|
76
|
+
await withLock(() => {
|
|
77
|
+
const s = read();
|
|
78
|
+
s[provider] = cred;
|
|
79
|
+
write(s);
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function deleteCredential(provider: string): Promise<void> {
|
|
84
|
+
await withLock(() => {
|
|
85
|
+
const s = read();
|
|
86
|
+
delete s[provider];
|
|
87
|
+
write(s);
|
|
88
|
+
});
|
|
89
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Identity verification for "sign in with GitHub / Google". A login token is an IDENTITY
|
|
2
|
+
// ("this is user X"), not a provider key — so the backend verifies it with the provider and
|
|
3
|
+
// (optionally) checks an allowlist, instead of using it to call a model. Results are cached
|
|
4
|
+
// briefly to avoid hitting the provider on every request.
|
|
5
|
+
|
|
6
|
+
interface Identity {
|
|
7
|
+
provider: string;
|
|
8
|
+
user: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const cache = new Map<string, { id: Identity; exp: number }>();
|
|
12
|
+
const TTL = 5 * 60_000;
|
|
13
|
+
|
|
14
|
+
async function verifyGitHub(token: string): Promise<Identity | null> {
|
|
15
|
+
const r = await fetch("https://api.github.com/user", {
|
|
16
|
+
headers: { authorization: `Bearer ${token}`, "user-agent": "ada", accept: "application/vnd.github+json" },
|
|
17
|
+
});
|
|
18
|
+
if (!r.ok) return null;
|
|
19
|
+
const j = (await r.json()) as { login?: string };
|
|
20
|
+
return j.login ? { provider: "github", user: j.login } : null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function verifyGoogle(token: string): Promise<Identity | null> {
|
|
24
|
+
const r = await fetch("https://www.googleapis.com/oauth2/v3/userinfo", { headers: { authorization: `Bearer ${token}` } });
|
|
25
|
+
if (!r.ok) return null;
|
|
26
|
+
const j = (await r.json()) as { email?: string };
|
|
27
|
+
return j.email ? { provider: "google", user: j.email } : null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Resolve a bearer token to an identity (GitHub or Google), or null. */
|
|
31
|
+
export async function verifyIdentity(token: string): Promise<Identity | null> {
|
|
32
|
+
if (!token) return null;
|
|
33
|
+
const hit = cache.get(token);
|
|
34
|
+
if (hit && hit.exp > Date.now()) return hit.id;
|
|
35
|
+
|
|
36
|
+
let id: Identity | null = null;
|
|
37
|
+
try {
|
|
38
|
+
if (/^gh[opsu]_/.test(token) || token.startsWith("github_pat_")) id = await verifyGitHub(token);
|
|
39
|
+
else if (token.startsWith("ya29.")) id = await verifyGoogle(token);
|
|
40
|
+
else id = (await verifyGitHub(token)) ?? (await verifyGoogle(token)); // unknown shape: try both
|
|
41
|
+
} catch {
|
|
42
|
+
id = null;
|
|
43
|
+
}
|
|
44
|
+
if (id) cache.set(token, { id, exp: Date.now() + TTL });
|
|
45
|
+
return id;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Allowed login users (GitHub logins / Google emails), or null = any authenticated user. */
|
|
49
|
+
export function allowedUsers(): string[] | null {
|
|
50
|
+
const v = process.env.ADA_ALLOWED_USERS;
|
|
51
|
+
if (!v) return null;
|
|
52
|
+
return v.split(",").map((s) => s.trim()).filter(Boolean);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function isAllowed(user: string): boolean {
|
|
56
|
+
const a = allowedUsers();
|
|
57
|
+
return !a || a.includes(user);
|
|
58
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// ada backend — the Cursor-style routing layer.
|
|
2
|
+
// Client → here (auth → route → dispatch to an adapter) → upstream providers.
|
|
3
|
+
// Provider keys live ONLY here; the client never sees them.
|
|
4
|
+
|
|
5
|
+
import { createServer } from "node:http";
|
|
6
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
7
|
+
import { PORT, PROVIDERS, clientKeys, configuredProviders, isConfigured } from "./config.ts";
|
|
8
|
+
import { allowedUsers, isAllowed, verifyIdentity } from "./identity.ts";
|
|
9
|
+
import { adapterFor } from "./providers/registry.ts";
|
|
10
|
+
import { route } from "./router.ts";
|
|
11
|
+
|
|
12
|
+
function readBody(req: IncomingMessage): Promise<string> {
|
|
13
|
+
return new Promise((resolve, reject) => {
|
|
14
|
+
let data = "";
|
|
15
|
+
req.on("data", (c) => (data += c));
|
|
16
|
+
req.on("end", () => resolve(data));
|
|
17
|
+
req.on("error", reject);
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function locked(): boolean {
|
|
22
|
+
return clientKeys() !== null || allowedUsers() !== null || !!process.env.ADA_REQUIRE_LOGIN;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A request is allowed if it carries a known static client key, OR a valid GitHub/Google
|
|
26
|
+
* login token (allowlisted). With nothing configured, the backend is open (dev mode). */
|
|
27
|
+
async function authorized(req: IncomingMessage): Promise<boolean> {
|
|
28
|
+
if (!locked()) return true; // dev mode: no auth configured
|
|
29
|
+
const h = req.headers["authorization"];
|
|
30
|
+
const token = typeof h === "string" && h.startsWith("Bearer ") ? h.slice(7) : "";
|
|
31
|
+
if (!token) return false;
|
|
32
|
+
const keys = clientKeys();
|
|
33
|
+
if (keys?.includes(token)) return true; // static client key
|
|
34
|
+
const id = await verifyIdentity(token); // GitHub / Google login
|
|
35
|
+
return !!id && isAllowed(id.user);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function json(res: ServerResponse, status: number, obj: unknown): void {
|
|
39
|
+
res.writeHead(status, { "content-type": "application/json" });
|
|
40
|
+
res.end(JSON.stringify(obj));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function handleModels(res: ServerResponse): Promise<void> {
|
|
44
|
+
const data: Array<{ id: string; object: "model"; owned_by: string }> = [];
|
|
45
|
+
for (const p of configuredProviders()) {
|
|
46
|
+
const ids = await adapterFor(p).listModels(p);
|
|
47
|
+
for (const id of ids) data.push({ id, object: "model", owned_by: p });
|
|
48
|
+
}
|
|
49
|
+
json(res, 200, { object: "list", data });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function handleChat(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
53
|
+
const raw = await readBody(req);
|
|
54
|
+
let body: Record<string, unknown>;
|
|
55
|
+
try {
|
|
56
|
+
body = JSON.parse(raw);
|
|
57
|
+
} catch {
|
|
58
|
+
return json(res, 400, { error: { message: "invalid JSON body" } });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const model = String(body.model ?? "");
|
|
62
|
+
if (!model) return json(res, 400, { error: { message: "missing 'model'" } });
|
|
63
|
+
|
|
64
|
+
const provider = route(model, typeof body.provider === "string" ? body.provider : undefined);
|
|
65
|
+
if (!isConfigured(provider)) {
|
|
66
|
+
return json(res, 400, {
|
|
67
|
+
error: { message: `provider '${provider}' not configured — set ${PROVIDERS[provider].keyEnv} on the backend` },
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
delete body.provider; // our routing hint; never forward it upstream
|
|
72
|
+
await adapterFor(provider).chat({ provider, model, body, res });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const server = createServer(async (req, res) => {
|
|
76
|
+
try {
|
|
77
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
78
|
+
if (url.pathname === "/" || url.pathname === "/health") {
|
|
79
|
+
res.writeHead(200, { "content-type": "text/plain" });
|
|
80
|
+
return res.end("ada backend ok");
|
|
81
|
+
}
|
|
82
|
+
if (req.method === "GET" && url.pathname === "/v1/whoami") {
|
|
83
|
+
if (!(await authorized(req))) return json(res, 401, { error: { message: "not logged in" } });
|
|
84
|
+
return json(res, 200, { ok: true });
|
|
85
|
+
}
|
|
86
|
+
if (req.method === "GET" && url.pathname === "/v1/models") {
|
|
87
|
+
if (!(await authorized(req))) return json(res, 401, { error: { message: "unauthorized — invalid client key or login" } });
|
|
88
|
+
return await handleModels(res);
|
|
89
|
+
}
|
|
90
|
+
if (req.method === "POST" && url.pathname === "/v1/chat/completions") {
|
|
91
|
+
if (!(await authorized(req))) return json(res, 401, { error: { message: "unauthorized — invalid client key or login" } });
|
|
92
|
+
return await handleChat(req, res);
|
|
93
|
+
}
|
|
94
|
+
return json(res, 404, { error: { message: "not found" } });
|
|
95
|
+
} catch (err) {
|
|
96
|
+
if (!res.headersSent) json(res, 500, { error: { message: err instanceof Error ? err.message : String(err) } });
|
|
97
|
+
else
|
|
98
|
+
try {
|
|
99
|
+
res.end();
|
|
100
|
+
} catch {
|
|
101
|
+
/* ignore */
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
server.listen(PORT, () => {
|
|
107
|
+
const auth = locked()
|
|
108
|
+
? `auth ON (client keys + GitHub/Google login${allowedUsers() ? `, allowlist: ${allowedUsers()!.length}` : ""})`
|
|
109
|
+
: "AUTH DISABLED (dev) — set ADA_CLIENT_KEYS or ADA_ALLOWED_USERS to lock down";
|
|
110
|
+
const provs = configuredProviders();
|
|
111
|
+
console.log(`ada backend → http://localhost:${PORT} [${auth}]`);
|
|
112
|
+
console.log(`providers: ${provs.length ? provs.join(", ") : "(none configured — set provider API keys)"}`);
|
|
113
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// OAuth 2.0 Device Authorization Grant (RFC 8628). Works against any compliant provider
|
|
2
|
+
// (GitHub, Google, …). Provider client IDs / endpoints are env-driven because they are
|
|
3
|
+
// provider-specific: ADA_OAUTH_<PROVIDER>_{CLIENT_ID,DEVICE_URL,TOKEN_URL,SCOPE}.
|
|
4
|
+
|
|
5
|
+
import { setCredential } from "./credentials.ts";
|
|
6
|
+
|
|
7
|
+
export interface OAuthConfig {
|
|
8
|
+
clientId: string;
|
|
9
|
+
deviceUrl: string;
|
|
10
|
+
tokenUrl: string;
|
|
11
|
+
scope?: string;
|
|
12
|
+
clientSecret?: string; // GitHub device flow omits this; Google requires it
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Built-in OAuth app config. A client_id and these endpoints are PUBLIC (they show up in browser
|
|
16
|
+
// URLs) — they identify the *app* (ada), not the user — so we ship them. Like `gh`, the user sets
|
|
17
|
+
// nothing. Env vars (ADA_OAUTH_<P>_*) override, e.g. to point ada at your own OAuth app.
|
|
18
|
+
const DEFAULTS: Record<string, Partial<OAuthConfig>> = {
|
|
19
|
+
github: {
|
|
20
|
+
clientId: "Ov23lirXtvfJWAt9C8et",
|
|
21
|
+
deviceUrl: "https://github.com/login/device/code",
|
|
22
|
+
tokenUrl: "https://github.com/login/oauth/access_token",
|
|
23
|
+
scope: "read:user",
|
|
24
|
+
},
|
|
25
|
+
google: {
|
|
26
|
+
// Google device flow needs a client_id (+ secret) from your own GCP project — set via env.
|
|
27
|
+
deviceUrl: "https://oauth2.googleapis.com/device/code",
|
|
28
|
+
tokenUrl: "https://oauth2.googleapis.com/token",
|
|
29
|
+
scope: "openid email",
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export function oauthConfig(provider: string): OAuthConfig | null {
|
|
34
|
+
const up = provider.toUpperCase();
|
|
35
|
+
const d = DEFAULTS[provider] ?? {};
|
|
36
|
+
const clientId = process.env[`ADA_OAUTH_${up}_CLIENT_ID`] ?? d.clientId;
|
|
37
|
+
const deviceUrl = process.env[`ADA_OAUTH_${up}_DEVICE_URL`] ?? d.deviceUrl;
|
|
38
|
+
const tokenUrl = process.env[`ADA_OAUTH_${up}_TOKEN_URL`] ?? d.tokenUrl;
|
|
39
|
+
if (!clientId || !deviceUrl || !tokenUrl) return null;
|
|
40
|
+
return {
|
|
41
|
+
clientId,
|
|
42
|
+
deviceUrl,
|
|
43
|
+
tokenUrl,
|
|
44
|
+
scope: process.env[`ADA_OAUTH_${up}_SCOPE`] ?? d.scope,
|
|
45
|
+
clientSecret: process.env[`ADA_OAUTH_${up}_CLIENT_SECRET`] ?? d.clientSecret,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function postForm(url: string, body: Record<string, string>): Promise<Record<string, unknown>> {
|
|
50
|
+
const res = await fetch(url, {
|
|
51
|
+
method: "POST",
|
|
52
|
+
headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
|
|
53
|
+
body: new URLSearchParams(body),
|
|
54
|
+
});
|
|
55
|
+
return (await res.json()) as Record<string, unknown>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Run the device flow: print the user code, poll for the token, store it on success. */
|
|
59
|
+
export async function deviceLogin(provider: string, cfg: OAuthConfig, print: (s: string) => void): Promise<void> {
|
|
60
|
+
const secret: Record<string, string> = cfg.clientSecret ? { client_secret: cfg.clientSecret } : {};
|
|
61
|
+
const dev = await postForm(cfg.deviceUrl, { client_id: cfg.clientId, scope: cfg.scope ?? "", ...secret });
|
|
62
|
+
const deviceCode = dev.device_code as string;
|
|
63
|
+
if (!deviceCode) throw new Error(`device request failed: ${JSON.stringify(dev)}`);
|
|
64
|
+
print(`\nTo log in to ${provider}, open:\n ${dev.verification_uri ?? dev.verification_uri_complete ?? dev.verification_url}`);
|
|
65
|
+
print(`and enter the code: ${dev.user_code}\n`);
|
|
66
|
+
|
|
67
|
+
const interval = (Number(dev.interval) || 5) * 1000;
|
|
68
|
+
const deadline = Date.now() + (Number(dev.expires_in) || 900) * 1000;
|
|
69
|
+
while (Date.now() < deadline) {
|
|
70
|
+
await new Promise((r) => setTimeout(r, interval));
|
|
71
|
+
const tok = await postForm(cfg.tokenUrl, {
|
|
72
|
+
client_id: cfg.clientId,
|
|
73
|
+
device_code: deviceCode,
|
|
74
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
75
|
+
...secret,
|
|
76
|
+
});
|
|
77
|
+
if (tok.access_token) {
|
|
78
|
+
await setCredential(provider, {
|
|
79
|
+
type: "oauth",
|
|
80
|
+
access: tok.access_token as string,
|
|
81
|
+
refresh: tok.refresh_token as string | undefined,
|
|
82
|
+
expires: tok.expires_in ? Date.now() + Number(tok.expires_in) * 1000 : undefined,
|
|
83
|
+
});
|
|
84
|
+
print(`Logged in to ${provider}.`);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const err = tok.error as string | undefined;
|
|
88
|
+
if (err && err !== "authorization_pending" && err !== "slow_down") {
|
|
89
|
+
throw new Error((tok.error_description as string) ?? err);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
throw new Error("device login timed out");
|
|
93
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// The provider adapter pattern.
|
|
2
|
+
//
|
|
3
|
+
// One adapter per WIRE FORMAT — not per model, not per provider. Most providers speak
|
|
4
|
+
// the OpenAI Chat Completions format and share `openAICompatAdapter`; only providers whose
|
|
5
|
+
// format genuinely diverges (e.g. Anthropic's Messages API) get their own adapter.
|
|
6
|
+
//
|
|
7
|
+
// Every adapter takes an OpenAI-format request and streams an OpenAI-format SSE response,
|
|
8
|
+
// so the client only ever deals with one wire format.
|
|
9
|
+
|
|
10
|
+
import type { ServerResponse } from "node:http";
|
|
11
|
+
import type { ProviderName } from "../../shared/types.ts";
|
|
12
|
+
|
|
13
|
+
export interface ChatRequest {
|
|
14
|
+
provider: ProviderName;
|
|
15
|
+
model: string;
|
|
16
|
+
body: Record<string, unknown>; // an OpenAI Chat Completions request body
|
|
17
|
+
res: ServerResponse;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface Adapter {
|
|
21
|
+
/** Stream an OpenAI-format chat completion (SSE) for `req.provider`. */
|
|
22
|
+
chat(req: ChatRequest): Promise<void>;
|
|
23
|
+
/** List the model ids this provider exposes. */
|
|
24
|
+
listModels(provider: ProviderName): Promise<string[]>;
|
|
25
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// Native Anthropic adapter. Anthropic's Messages API is NOT OpenAI-shaped, so this adapter
|
|
2
|
+
// translates the OpenAI request → Anthropic Messages, streams it, and re-emits Anthropic
|
|
3
|
+
// events as OpenAI SSE chunks. The @anthropic-ai/sdk is loaded lazily (top-level `import type`
|
|
4
|
+
// is erased at runtime; the dynamic import() only runs the first time a Claude request
|
|
5
|
+
// arrives) — so the SDK never loads unless Anthropic is actually used.
|
|
6
|
+
|
|
7
|
+
import type AnthropicSDK from "@anthropic-ai/sdk";
|
|
8
|
+
import { providerKey } from "../config.ts";
|
|
9
|
+
import { endStream, SSE_HEADERS, writeChunk } from "../sse.ts";
|
|
10
|
+
import type { Adapter, ChatRequest } from "./adapter.ts";
|
|
11
|
+
|
|
12
|
+
let cached: AnthropicSDK | null = null;
|
|
13
|
+
async function getClient(): Promise<AnthropicSDK> {
|
|
14
|
+
if (!cached) {
|
|
15
|
+
const { default: Anthropic } = await import("@anthropic-ai/sdk");
|
|
16
|
+
cached = new Anthropic({ apiKey: providerKey("anthropic") });
|
|
17
|
+
}
|
|
18
|
+
return cached;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type OAIMessage = {
|
|
22
|
+
role: string;
|
|
23
|
+
content?: unknown;
|
|
24
|
+
tool_calls?: Array<{ id: string; function: { name: string; arguments: string } }>;
|
|
25
|
+
tool_call_id?: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
type Block = Record<string, unknown>;
|
|
29
|
+
|
|
30
|
+
/** OpenAI messages[] → Anthropic { system, messages[] }. */
|
|
31
|
+
function convert(messages: OAIMessage[]): { system?: string; messages: Block[] } {
|
|
32
|
+
const system: string[] = [];
|
|
33
|
+
const out: Block[] = [];
|
|
34
|
+
|
|
35
|
+
for (let i = 0; i < messages.length; i++) {
|
|
36
|
+
const msg = messages[i]!;
|
|
37
|
+
|
|
38
|
+
if (msg.role === "system") {
|
|
39
|
+
if (typeof msg.content === "string") system.push(msg.content);
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (msg.role === "tool") {
|
|
44
|
+
// Merge a run of consecutive tool messages into one Anthropic user turn.
|
|
45
|
+
const results: Block[] = [];
|
|
46
|
+
let j = i;
|
|
47
|
+
while (j < messages.length && messages[j]!.role === "tool") {
|
|
48
|
+
const t = messages[j]!;
|
|
49
|
+
results.push({ type: "tool_result", tool_use_id: t.tool_call_id, content: String(t.content ?? "") });
|
|
50
|
+
j++;
|
|
51
|
+
}
|
|
52
|
+
out.push({ role: "user", content: results });
|
|
53
|
+
i = j - 1;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (msg.role === "assistant") {
|
|
58
|
+
const blocks: Block[] = [];
|
|
59
|
+
if (msg.content) blocks.push({ type: "text", text: String(msg.content) });
|
|
60
|
+
for (const tc of msg.tool_calls ?? []) {
|
|
61
|
+
let input: unknown = {};
|
|
62
|
+
try {
|
|
63
|
+
input = JSON.parse(tc.function.arguments || "{}");
|
|
64
|
+
} catch {
|
|
65
|
+
input = {};
|
|
66
|
+
}
|
|
67
|
+
blocks.push({ type: "tool_use", id: tc.id, name: tc.function.name, input });
|
|
68
|
+
}
|
|
69
|
+
out.push({ role: "assistant", content: blocks.length ? blocks : [{ type: "text", text: "(no content)" }] });
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (Array.isArray(msg.content)) {
|
|
74
|
+
// multimodal user turn: translate OpenAI parts → Anthropic blocks (text + base64 images)
|
|
75
|
+
const blocks: Block[] = (msg.content as Array<Record<string, unknown>>).map((part) => {
|
|
76
|
+
if (part.type === "image_url") {
|
|
77
|
+
const url = String((part.image_url as { url?: string })?.url ?? "");
|
|
78
|
+
const m = /^data:(.+?);base64,(.*)$/.exec(url);
|
|
79
|
+
if (m) return { type: "image", source: { type: "base64", media_type: m[1], data: m[2] } };
|
|
80
|
+
return { type: "image", source: { type: "url", url } };
|
|
81
|
+
}
|
|
82
|
+
return { type: "text", text: String(part.text ?? "") };
|
|
83
|
+
});
|
|
84
|
+
out.push({ role: "user", content: blocks });
|
|
85
|
+
} else {
|
|
86
|
+
out.push({ role: "user", content: typeof msg.content === "string" ? msg.content : String(msg.content ?? "") });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return { system: system.length ? system.join("\n\n") : undefined, messages: out };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function mapStop(reason: string | null | undefined): string {
|
|
94
|
+
switch (reason) {
|
|
95
|
+
case "tool_use":
|
|
96
|
+
return "tool_calls";
|
|
97
|
+
case "max_tokens":
|
|
98
|
+
return "length";
|
|
99
|
+
default:
|
|
100
|
+
return "stop";
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export const anthropicAdapter: Adapter = {
|
|
105
|
+
async chat({ body, res }: ChatRequest): Promise<void> {
|
|
106
|
+
const id = `chatcmpl-${Math.random().toString(16).slice(2, 12)}`;
|
|
107
|
+
const created = Math.floor(Date.now() / 1000);
|
|
108
|
+
const model = String(body.model);
|
|
109
|
+
const chunk = (delta: Block, finish: string | null = null) =>
|
|
110
|
+
writeChunk(res, { id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta, finish_reason: finish }] });
|
|
111
|
+
|
|
112
|
+
res.writeHead(200, SSE_HEADERS);
|
|
113
|
+
chunk({ role: "assistant" });
|
|
114
|
+
|
|
115
|
+
let stop = "stop";
|
|
116
|
+
let toolIndex = -1;
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
const client = await getClient();
|
|
120
|
+
const { system, messages } = convert((body.messages as OAIMessage[]) ?? []);
|
|
121
|
+
const tools = (
|
|
122
|
+
(body.tools as Array<{ function: { name: string; description?: string; parameters?: unknown } }>) ?? []
|
|
123
|
+
).map((t) => ({
|
|
124
|
+
name: t.function.name,
|
|
125
|
+
description: t.function.description ?? "",
|
|
126
|
+
input_schema: (t.function.parameters as object) ?? { type: "object", properties: {} },
|
|
127
|
+
}));
|
|
128
|
+
|
|
129
|
+
// Prompt caching: mark the stable prefix (system + tools) cacheable. ADA_CACHE_TTL=1h opts
|
|
130
|
+
// into the 1-hour cache (otherwise Anthropic's default 5-minute ephemeral cache applies).
|
|
131
|
+
const ttl1h = process.env.ADA_CACHE_TTL === "1h";
|
|
132
|
+
const cacheControl: Record<string, string> = { type: "ephemeral" };
|
|
133
|
+
if (ttl1h) cacheControl.ttl = "1h";
|
|
134
|
+
if (tools.length) (tools[tools.length - 1] as Record<string, unknown>).cache_control = cacheControl;
|
|
135
|
+
const systemParam = system ? [{ type: "text", text: system, cache_control: cacheControl }] : undefined;
|
|
136
|
+
|
|
137
|
+
const params = {
|
|
138
|
+
model,
|
|
139
|
+
max_tokens: typeof body.max_tokens === "number" ? body.max_tokens : 8192,
|
|
140
|
+
...(systemParam ? { system: systemParam } : {}),
|
|
141
|
+
messages: messages as unknown as AnthropicSDK.MessageParam[],
|
|
142
|
+
...(tools.length ? { tools: tools as AnthropicSDK.Tool[] } : {}),
|
|
143
|
+
} as unknown as Parameters<typeof client.messages.stream>[0];
|
|
144
|
+
|
|
145
|
+
const stream = client.messages.stream(
|
|
146
|
+
params,
|
|
147
|
+
ttl1h ? { headers: { "anthropic-beta": "extended-cache-ttl-2025-04-11" } } : undefined,
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
for await (const event of stream) {
|
|
151
|
+
if (event.type === "content_block_start") {
|
|
152
|
+
const cb = event.content_block as { type: string; id?: string; name?: string };
|
|
153
|
+
if (cb.type === "tool_use") {
|
|
154
|
+
toolIndex++;
|
|
155
|
+
chunk({ tool_calls: [{ index: toolIndex, id: cb.id, type: "function", function: { name: cb.name, arguments: "" } }] });
|
|
156
|
+
}
|
|
157
|
+
} else if (event.type === "content_block_delta") {
|
|
158
|
+
const d = event.delta as { type: string; text?: string; partial_json?: string };
|
|
159
|
+
if (d.type === "text_delta") chunk({ content: d.text });
|
|
160
|
+
else if (d.type === "input_json_delta") chunk({ tool_calls: [{ index: toolIndex, function: { arguments: d.partial_json } }] });
|
|
161
|
+
} else if (event.type === "message_delta") {
|
|
162
|
+
const reason = (event.delta as { stop_reason?: string | null }).stop_reason;
|
|
163
|
+
if (reason) stop = mapStop(reason);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
chunk({}, stop);
|
|
168
|
+
endStream(res);
|
|
169
|
+
} catch (err) {
|
|
170
|
+
chunk({ content: `\n[backend: anthropic error: ${err instanceof Error ? err.message : String(err)}]` }, "stop");
|
|
171
|
+
endStream(res);
|
|
172
|
+
}
|
|
173
|
+
},
|
|
174
|
+
|
|
175
|
+
async listModels(): Promise<string[]> {
|
|
176
|
+
const key = providerKey("anthropic");
|
|
177
|
+
if (!key) return [];
|
|
178
|
+
try {
|
|
179
|
+
const r = await fetch("https://api.anthropic.com/v1/models?limit=1000", {
|
|
180
|
+
headers: { "x-api-key": key, "anthropic-version": "2023-06-01" },
|
|
181
|
+
});
|
|
182
|
+
if (!r.ok) return [];
|
|
183
|
+
const j = (await r.json()) as { data?: Array<{ id?: unknown }> };
|
|
184
|
+
return (j.data ?? []).map((m) => m.id).filter((x): x is string => typeof x === "string");
|
|
185
|
+
} catch {
|
|
186
|
+
return [];
|
|
187
|
+
}
|
|
188
|
+
},
|
|
189
|
+
};
|