diff --git a/bun.lock b/bun.lock index 4808ea2..2dc28d8 100644 --- a/bun.lock +++ b/bun.lock @@ -28,6 +28,7 @@ "@ai-sdk/anthropic": "^3.0.38", "@ai-sdk/openai": "^3.0.25", "@enfinyte/config": "workspace:*", + "@enfinyte/services": "workspace:*", "@tokenizer/http": "^0.9.2", "ai": "^6.0.69", "common": "workspace:*", @@ -36,8 +37,6 @@ "kysely": "^0.28.11", "pg": "^8.18.0", "redis": "^5.11.0", - "resolver": "workspace:*", - "vault": "workspace:*", }, "devDependencies": { "@types/bun": "latest", @@ -51,6 +50,7 @@ "dependencies": { "@better-auth/api-key": "^1.5.5", "@enfinyte/config": "workspace:*", + "@enfinyte/services": "workspace:*", "@types/pg": "^8.16.0", "better-auth": "^1.4.18", "common": "workspace:*", @@ -58,10 +58,7 @@ "effect": "^3.19.15", "hono": "^4.11.7", "kysely": "^0.28.11", - "ledger": "workspace:*", "pg": "^8.18.0", - "resolver": "workspace:*", - "vault": "workspace:*", }, "devDependencies": { "@types/bun": "latest", @@ -175,6 +172,20 @@ "typescript": "^5", }, }, + "packages/services": { + "name": "@enfinyte/services", + "dependencies": { + "ledger": "workspace:*", + "resolver": "workspace:*", + "vault": "workspace:*", + }, + "devDependencies": { + "@types/bun": "latest", + }, + "peerDependencies": { + "typescript": "^5", + }, + }, "packages/vault": { "name": "vault", "dependencies": { @@ -520,6 +531,8 @@ "@enfinyte/config": ["@enfinyte/config@workspace:packages/config"], + "@enfinyte/services": ["@enfinyte/services@workspace:packages/services"], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], "@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="], @@ -3336,6 +3349,10 @@ "@emotion/serialize/@emotion/unitless": ["@emotion/unitless@0.10.0", "", {}, "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg=="], + "@enfinyte/config/@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="], + + "@enfinyte/services/@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="], + "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], "@inquirer/core/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], @@ -3402,12 +3419,18 @@ "ajv-formats/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], + "api_platform/@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="], + "babel-plugin-macros/cosmiconfig": ["cosmiconfig@7.1.0", "", { "dependencies": { "@types/parse-json": "^4.0.0", "import-fresh": "^3.2.1", "parse-json": "^5.0.0", "path-type": "^4.0.0", "yaml": "^1.10.0" } }, "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA=="], + "backend/@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="], + "cloudflare/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], "cloudflare/node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], + "common/@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="], + "cytoscape-fcose/cose-base": ["cose-base@2.2.0", "", { "dependencies": { "layout-base": "^2.0.0" } }, "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g=="], "d3-dsv/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="], @@ -3456,6 +3479,8 @@ "katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], + "ledger/@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="], + "log-symbols/is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="], "mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], @@ -3486,6 +3511,8 @@ "react-rnd/tslib": ["tslib@2.6.2", "", {}, "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q=="], + "resolver/@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="], + "restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], "router/path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="], @@ -3502,6 +3529,8 @@ "terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], + "vault/@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="], + "wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], "@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], @@ -3524,6 +3553,10 @@ "@dotenvx/dotenvx/which/isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="], + "@enfinyte/config/@types/bun/bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], + + "@enfinyte/services/@types/bun/bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], + "@inquirer/core/wrap-ansi/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "@inquirer/core/wrap-ansi/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], @@ -3602,12 +3635,18 @@ "ajv-formats/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + "api_platform/@types/bun/bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], + "babel-plugin-macros/cosmiconfig/yaml": ["yaml@1.10.2", "", {}, "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg=="], + "backend/@types/bun/bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], + "cloudflare/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], "cloudflare/node-fetch/whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], + "common/@types/bun/bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], + "cytoscape-fcose/cose-base/layout-base": ["layout-base@2.0.1", "", {}, "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg=="], "d3-sankey/d3-array/internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="], @@ -3622,12 +3661,18 @@ "glob/minimatch/brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="], + "ledger/@types/bun/bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], + "msw/yargs/cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], "msw/yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "msw/yargs/yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], + "resolver/@types/bun/bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], + + "vault/@types/bun/bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], + "@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], "@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], diff --git a/packages/api_platform/package.json b/packages/api_platform/package.json index 6e26799..1711d9c 100644 --- a/packages/api_platform/package.json +++ b/packages/api_platform/package.json @@ -19,8 +19,7 @@ "kysely": "^0.28.11", "pg": "^8.18.0", "redis": "^5.11.0", - "resolver": "workspace:*", - "vault": "workspace:*" + "@enfinyte/services": "workspace:*" }, "devDependencies": { "@types/bun": "latest" diff --git a/packages/api_platform/src/index.ts b/packages/api_platform/src/index.ts index bfa8cfb..9bf361b 100644 --- a/packages/api_platform/src/index.ts +++ b/packages/api_platform/src/index.ts @@ -1,9 +1,7 @@ import { HttpMiddleware, HttpServer } from "@effect/platform"; import { BunContext, BunHttpServer, BunRuntime } from "@effect/platform-bun"; import { Effect, flow, Layer } from "effect"; -import { fromEnv as LedgerServiceLive } from "ledger"; -import { ResolverServiceLive } from "resolver"; -import { VaultServiceLive } from "vault"; +import { LedgerServiceLive, ResolverServiceLive, VaultServiceLive } from "@enfinyte/services"; import { router } from "./routes/index"; import { AppConfig, AppConfigLive } from "./services/config"; diff --git a/packages/api_platform/src/routes/v1/__tests__/responses-streaming.test.ts b/packages/api_platform/src/routes/v1/__tests__/responses-streaming.test.ts index 90591a7..f2a0f3b 100644 --- a/packages/api_platform/src/routes/v1/__tests__/responses-streaming.test.ts +++ b/packages/api_platform/src/routes/v1/__tests__/responses-streaming.test.ts @@ -9,7 +9,7 @@ import type { Kysely } from "kysely"; import { router } from "../.."; import { AppConfig } from "../../../services/config"; import { DatabaseService, DatabaseServiceError } from "../../../services/database/postgres"; -import { VaultService } from "vault"; +import { VaultService, LedgerService, ResolverService } from "@enfinyte/services"; type StreamResult = ReturnType; @@ -79,9 +79,14 @@ mock.module(aiModulePath, () => ({ ), executeStream: (_body: CreateResponseBody) => Effect.succeed({ - stream: mockStream(), - result: mockStreamResult(), + result: { + ...mockStreamResult(), + fullStream: mockStream(), + }, resolvedModelAndProvider: { provider: "openai", model: "gpt-4o-mini" }, + resolutionLatencyMs: 0, + llmStartedAt: Date.now(), + ttftMs: 5, }), })); @@ -218,11 +223,38 @@ const startApiServer = async (port: number, backendUrl: string) => { }), ); + const LedgerServiceTest = Layer.succeed( + LedgerService, + LedgerService.of({ + insertTransaction: (t) => Effect.succeed(t), + getOverview: () => Effect.succeed({ + total_requests: 0, avg_latency_ms: 0, p50_latency_ms: 0, + p95_latency_ms: 0, p99_latency_ms: 0, avg_resolution_latency_ms: 0, + total_cost_usd: 0, error_rate: 0, total_errors: 0, total_rate_limits: 0, + }), + getTimeSeries: () => Effect.succeed([]), + getProviderModelLatency: () => Effect.succeed([]), + getDailyModelCost: () => Effect.succeed([]), + getErrorRate: () => Effect.succeed([]), + }), + ); + + const ResolverServiceTest = Layer.succeed( + ResolverService, + ResolverService.of({ + getAvailableModels: () => Effect.succeed({}), + resolve: () => Effect.succeed([{ provider: "openai", model: "gpt-4o-mini", category: null }]), + getCostForModel: () => Effect.succeed(null), + }), + ); + const HttpServerLayer = BunHttpServer.layer({ port }); const AllServices = Layer.mergeAll( AppConfigTest, DatabaseServiceTest, VaultServiceTest, + LedgerServiceTest, + ResolverServiceTest, BunContext.layer, ); const AllServicesAndHttpServer = Layer.mergeAll(AllServices, HttpServerLayer); diff --git a/packages/api_platform/src/services/ai/__tests__/convertAISdkStreamTextToStreamingEvents.test.ts b/packages/api_platform/src/services/ai/__tests__/convertAISdkStreamTextToStreamingEvents.test.ts deleted file mode 100644 index 8a20dba..0000000 --- a/packages/api_platform/src/services/ai/__tests__/convertAISdkStreamTextToStreamingEvents.test.ts +++ /dev/null @@ -1,573 +0,0 @@ -import { describe, it, expect } from "bun:test"; -import { convertAISdkStreamTextToStreamingEvents } from "../convertAISdkStreamTextToStreamingEvents"; -import type { StreamingEvent } from "common"; - -type MockPart = { type: string; [key: string]: unknown }; - -async function* mockTextStreamParts(parts: MockPart[]): AsyncIterable { - for (const part of parts) { - yield part; - } -} - -async function collectEvents( - stream: AsyncIterable, -): Promise { - const collected: StreamingEvent[] = []; - for await (const batch of stream) { - collected.push(...batch); - } - return collected; -} - -describe("convertAISdkStreamTextToStreamingEvents", () => { - describe("text message lifecycle", () => { - it("produces correct events for text-start → text-delta → text-end", async () => { - const stream = mockTextStreamParts([ - { type: "text-start" }, - { type: "text-delta", text: "Hello" }, - { type: "text-delta", text: " world" }, - { type: "text-end" }, - { type: "finish", finishReason: "stop" }, - ]); - - const { events } = convertAISdkStreamTextToStreamingEvents( - stream as never, - "resp-test-1", - ); - const collected = await collectEvents(events); - - expect(collected).toHaveLength(7); - expect(collected[0]?.type).toBe("response.output_item.added"); - expect(collected[1]?.type).toBe("response.content_part.added"); - expect(collected[2]?.type).toBe("response.output_text.delta"); - expect(collected[3]?.type).toBe("response.output_text.delta"); - expect(collected[4]?.type).toBe("response.output_text.done"); - expect(collected[5]?.type).toBe("response.content_part.done"); - expect(collected[6]?.type).toBe("response.output_item.done"); - - const addedItem = (collected[0] as StreamingEvent & { item: Record }).item; - expect(addedItem.id).toBeString(); - expect(addedItem.status).toBe("in_progress"); - expect(addedItem.role).toBe("assistant"); - expect(addedItem.content).toEqual([]); - expect(addedItem).not.toHaveProperty("type"); - - const doneItem = (collected[6] as StreamingEvent & { item: Record }).item; - expect(doneItem.id).toBeString(); - expect(doneItem.status).toBe("completed"); - expect(doneItem.role).toBe("assistant"); - expect((doneItem.content as Array<{ type: string; text: string }>)[0]?.type).toBe("output_text"); - expect((doneItem.content as Array<{ type: string; text: string }>)[0]?.text).toBe("Hello world"); - expect(doneItem).not.toHaveProperty("type"); - }); - - it("accumulates text deltas and includes full text in done events", async () => { - const stream = mockTextStreamParts([ - { type: "text-start" }, - { type: "text-delta", text: "foo" }, - { type: "text-delta", text: "bar" }, - { type: "text-end" }, - ]); - - const { events } = convertAISdkStreamTextToStreamingEvents( - stream as never, - "resp-test-2", - ); - const collected = await collectEvents(events); - - const textDone = collected.find((e) => e.type === "response.output_text.done"); - expect(textDone).toBeDefined(); - expect((textDone as { text: string }).text).toBe("foobar"); - - const contentDone = collected.find((e) => e.type === "response.content_part.done"); - expect(contentDone).toBeDefined(); - expect((contentDone as { part: { text: string } }).part.text).toBe("foobar"); - }); - - it("sets output_index = 0 and content_index = 0 for first text item", async () => { - const stream = mockTextStreamParts([ - { type: "text-start" }, - { type: "text-delta", text: "x" }, - { type: "text-end" }, - ]); - - const { events } = convertAISdkStreamTextToStreamingEvents( - stream as never, - "resp-idx", - ); - const collected = await collectEvents(events); - - const added = collected[0] as StreamingEvent & { output_index: number }; - expect(added.output_index).toBe(0); - - const delta = collected[2] as StreamingEvent & { - output_index: number; - content_index: number; - }; - expect(delta.output_index).toBe(0); - expect(delta.content_index).toBe(0); - }); - - it("assigns consistent item_id to all events within an item", async () => { - const stream = mockTextStreamParts([ - { type: "text-start" }, - { type: "text-delta", text: "hi" }, - { type: "text-end" }, - ]); - - const { events } = convertAISdkStreamTextToStreamingEvents( - stream as never, - "resp-id", - ); - const collected = await collectEvents(events); - - const addedEvent = collected[0] as StreamingEvent & { item: { id: string; [key: string]: unknown } }; - const itemId = addedEvent.item.id; - expect(itemId).toBeTruthy(); - expect(addedEvent.item).toHaveProperty("status", "in_progress"); - expect(addedEvent.item).toHaveProperty("role", "assistant"); - - for (const event of collected) { - if ("item_id" in event) { - expect((event as { item_id: string }).item_id).toBe(itemId); - } - if ("item" in event) { - expect((event as { item: { id: string } }).item.id).toBe(itemId); - } - } - }); - }); - - describe("function call lifecycle", () => { - it("produces correct events for tool-input-start → tool-input-delta → tool-input-end", async () => { - const stream = mockTextStreamParts([ - { type: "tool-input-start", toolCallId: "call_abc", toolName: "get_weather" }, - { type: "tool-input-delta", argsTextDelta: '{"loc' }, - { type: "tool-input-delta", argsTextDelta: 'ation":"NYC"}' }, - { type: "tool-input-end" }, - ]); - - const { events } = convertAISdkStreamTextToStreamingEvents( - stream as never, - "resp-tool-1", - ); - const collected = await collectEvents(events); - - expect(collected).toHaveLength(7); - expect(collected[0]?.type).toBe("response.output_item.added"); - expect(collected[1]?.type).toBe("response.content_part.added"); - expect(collected[2]?.type).toBe("response.function_call_arguments.delta"); - expect(collected[3]?.type).toBe("response.function_call_arguments.delta"); - expect(collected[4]?.type).toBe("response.function_call_arguments.done"); - expect(collected[5]?.type).toBe("response.content_part.done"); - expect(collected[6]?.type).toBe("response.output_item.done"); - - const addedItem = (collected[0] as StreamingEvent & { item: Record }).item; - expect(addedItem.id).toBeString(); - expect(addedItem.status).toBe("in_progress"); - expect(addedItem.call_id).toBe("call_abc"); - expect(addedItem.name).toBe("get_weather"); - expect(addedItem.arguments).toBe(""); - expect(addedItem).not.toHaveProperty("type"); - - const doneItem = (collected[6] as StreamingEvent & { item: Record }).item; - expect(doneItem.id).toBeString(); - expect(doneItem.status).toBe("completed"); - expect(doneItem.call_id).toBe("call_abc"); - expect(doneItem.name).toBe("get_weather"); - expect(doneItem.arguments).toBe('{"location":"NYC"}'); - expect(doneItem).not.toHaveProperty("type"); - }); - - it("accumulates arguments and includes full args in done event", async () => { - const stream = mockTextStreamParts([ - { type: "tool-input-start", toolCallId: "call_1", toolName: "fn" }, - { type: "tool-input-delta", argsTextDelta: '{"a":' }, - { type: "tool-input-delta", argsTextDelta: "1}" }, - { type: "tool-input-end" }, - ]); - - const { events } = convertAISdkStreamTextToStreamingEvents( - stream as never, - "resp-tool-acc", - ); - const collected = await collectEvents(events); - - const argsDone = collected.find( - (e) => e.type === "response.function_call_arguments.done", - ); - expect(argsDone).toBeDefined(); - expect((argsDone as { arguments: string }).arguments).toBe('{"a":1}'); - }); - - it("stores function call in accumulated output items", async () => { - const stream = mockTextStreamParts([ - { type: "tool-input-start", toolCallId: "call_xyz", toolName: "search" }, - { type: "tool-input-delta", argsTextDelta: "{}" }, - { type: "tool-input-end" }, - ]); - - const { events, getAccumulatedState } = convertAISdkStreamTextToStreamingEvents( - stream as never, - "resp-tool-state", - ); - await collectEvents(events); - - const state = getAccumulatedState(); - expect(state.outputItems).toHaveLength(1); - expect(state.outputItems[0]?.type).toBe("function_call"); - - const fc = state.outputItems[0] as { - type: string; - call_id: string; - name: string; - arguments: string; - }; - expect(fc.call_id).toBe("call_xyz"); - expect(fc.name).toBe("search"); - expect(fc.arguments).toBe("{}"); - }); - }); - - describe("reasoning lifecycle", () => { - it("produces correct events for reasoning-start → reasoning-delta → reasoning-end", async () => { - const stream = mockTextStreamParts([ - { type: "reasoning-start" }, - { type: "reasoning-delta", text: "Let me " }, - { type: "reasoning-delta", text: "think..." }, - { type: "reasoning-end" }, - ]); - - const { events } = convertAISdkStreamTextToStreamingEvents( - stream as never, - "resp-reason-1", - ); - const collected = await collectEvents(events); - - expect(collected).toHaveLength(7); - expect(collected[0]?.type).toBe("response.output_item.added"); - expect(collected[1]?.type).toBe("response.content_part.added"); - expect(collected[2]?.type).toBe("response.reasoning.delta"); - expect(collected[3]?.type).toBe("response.reasoning.delta"); - expect(collected[4]?.type).toBe("response.reasoning.done"); - expect(collected[5]?.type).toBe("response.content_part.done"); - expect(collected[6]?.type).toBe("response.output_item.done"); - - const addedItem = (collected[0] as StreamingEvent & { item: Record }).item; - expect(addedItem.id).toBeString(); - expect(addedItem.summary).toEqual([]); - expect(addedItem).not.toHaveProperty("type"); - expect(addedItem).not.toHaveProperty("status"); - - const doneItem = (collected[6] as StreamingEvent & { item: Record }).item; - expect(doneItem.id).toBeString(); - expect(doneItem.summary).toEqual([]); - expect((doneItem.content as Array<{ type: string; text: string }>)[0]?.type).toBe("reasoning"); - expect((doneItem.content as Array<{ type: string; text: string }>)[0]?.text).toBe("Let me think..."); - expect(doneItem).not.toHaveProperty("type"); - expect(doneItem).not.toHaveProperty("status"); - }); - - it("accumulates reasoning text in done event", async () => { - const stream = mockTextStreamParts([ - { type: "reasoning-start" }, - { type: "reasoning-delta", text: "Step 1. " }, - { type: "reasoning-delta", text: "Step 2." }, - { type: "reasoning-end" }, - ]); - - const { events } = convertAISdkStreamTextToStreamingEvents( - stream as never, - "resp-reason-acc", - ); - const collected = await collectEvents(events); - - const reasoningDone = collected.find((e) => e.type === "response.reasoning.done"); - expect(reasoningDone).toBeDefined(); - expect((reasoningDone as { text: string }).text).toBe("Step 1. Step 2."); - }); - - it("stores reasoning in accumulated output items", async () => { - const stream = mockTextStreamParts([ - { type: "reasoning-start" }, - { type: "reasoning-delta", text: "hmm" }, - { type: "reasoning-end" }, - ]); - - const { events, getAccumulatedState } = convertAISdkStreamTextToStreamingEvents( - stream as never, - "resp-reason-state", - ); - await collectEvents(events); - - const state = getAccumulatedState(); - expect(state.outputItems).toHaveLength(1); - expect(state.outputItems[0]?.type).toBe("reasoning"); - }); - }); - - describe("multiple output items", () => { - it("increments output_index for each new item", async () => { - const stream = mockTextStreamParts([ - { type: "reasoning-start" }, - { type: "reasoning-delta", text: "think" }, - { type: "reasoning-end" }, - { type: "text-start" }, - { type: "text-delta", text: "Answer" }, - { type: "text-end" }, - { type: "tool-input-start", toolCallId: "call_1", toolName: "fn" }, - { type: "tool-input-delta", argsTextDelta: "{}" }, - { type: "tool-input-end" }, - ]); - - const { events } = convertAISdkStreamTextToStreamingEvents( - stream as never, - "resp-multi", - ); - const collected = await collectEvents(events); - - const addedEvents = collected.filter( - (e) => e.type === "response.output_item.added", - ) as Array; - - expect(addedEvents).toHaveLength(3); - expect(addedEvents[0]?.output_index).toBe(0); - expect(addedEvents[1]?.output_index).toBe(1); - expect(addedEvents[2]?.output_index).toBe(2); - }); - - it("accumulates all output items in state", async () => { - const stream = mockTextStreamParts([ - { type: "text-start" }, - { type: "text-delta", text: "Hello" }, - { type: "text-end" }, - { type: "tool-input-start", toolCallId: "call_2", toolName: "lookup" }, - { type: "tool-input-delta", argsTextDelta: '{"q":"x"}' }, - { type: "tool-input-end" }, - ]); - - const { events, getAccumulatedState } = convertAISdkStreamTextToStreamingEvents( - stream as never, - "resp-multi-state", - ); - await collectEvents(events); - - const state = getAccumulatedState(); - expect(state.outputItems).toHaveLength(2); - expect(state.outputItems[0]?.type).toBe("message"); - expect(state.outputItems[1]?.type).toBe("function_call"); - }); - }); - - describe("sequence numbers", () => { - it("assigns monotonically increasing sequence numbers starting at 0", async () => { - const stream = mockTextStreamParts([ - { type: "text-start" }, - { type: "text-delta", text: "A" }, - { type: "text-delta", text: "B" }, - { type: "text-end" }, - ]); - - const { events } = convertAISdkStreamTextToStreamingEvents( - stream as never, - "resp-seq", - ); - const collected = await collectEvents(events); - - const seqNumbers = collected.map((e) => e.sequence_number); - expect(seqNumbers[0]).toBe(0); - - for (let i = 1; i < seqNumbers.length; i++) { - const prev = seqNumbers[i - 1]; - const curr = seqNumbers[i]; - expect(curr).toBe((prev ?? 0) + 1); - } - }); - - it("continues sequence numbers across multiple items", async () => { - const stream = mockTextStreamParts([ - { type: "text-start" }, - { type: "text-delta", text: "hi" }, - { type: "text-end" }, - { type: "reasoning-start" }, - { type: "reasoning-delta", text: "hmm" }, - { type: "reasoning-end" }, - ]); - - const { events } = convertAISdkStreamTextToStreamingEvents( - stream as never, - "resp-seq-multi", - ); - const collected = await collectEvents(events); - - const seqNumbers = collected.map((e) => e.sequence_number); - for (let i = 1; i < seqNumbers.length; i++) { - const prev = seqNumbers[i - 1]; - const curr = seqNumbers[i]; - expect(curr).toBe((prev ?? 0) + 1); - } - }); - }); - - describe("error handling", () => { - it("produces error event from error part with object error", async () => { - const stream = mockTextStreamParts([ - { - type: "error", - error: { - type: "api_error", - code: "rate_limit_exceeded", - message: "Too many requests", - param: null, - }, - }, - ]); - - const { events } = convertAISdkStreamTextToStreamingEvents( - stream as never, - "resp-err", - ); - const collected = await collectEvents(events); - - expect(collected).toHaveLength(1); - const errorEvent = collected[0] as StreamingEvent & { - error: { type: string; code: string | null; message: string; param: string | null }; - }; - expect(errorEvent.type).toBe("error"); - expect(errorEvent.error.type).toBe("api_error"); - expect(errorEvent.error.code).toBe("rate_limit_exceeded"); - expect(errorEvent.error.message).toBe("Too many requests"); - expect(errorEvent.error.param).toBeNull(); - }); - - it("handles string error value", async () => { - const stream = mockTextStreamParts([ - { type: "error", error: "Something went wrong" }, - ]); - - const { events } = convertAISdkStreamTextToStreamingEvents( - stream as never, - "resp-err-str", - ); - const collected = await collectEvents(events); - - expect(collected).toHaveLength(1); - const errorEvent = collected[0] as StreamingEvent & { - error: { type: string; message: string }; - }; - expect(errorEvent.type).toBe("error"); - expect(errorEvent.error.type).toBe("error"); - expect(errorEvent.error.message).toBe("Something went wrong"); - }); - - it("handles error with name field as type fallback", async () => { - const stream = mockTextStreamParts([ - { - type: "error", - error: { name: "TimeoutError", message: "Request timed out" }, - }, - ]); - - const { events } = convertAISdkStreamTextToStreamingEvents( - stream as never, - "resp-err-name", - ); - const collected = await collectEvents(events); - - const errorEvent = collected[0] as StreamingEvent & { - error: { type: string; message: string }; - }; - expect(errorEvent.error.type).toBe("TimeoutError"); - expect(errorEvent.error.message).toBe("Request timed out"); - }); - - it("handles null error value with defaults", async () => { - const stream = mockTextStreamParts([ - { type: "error", error: null }, - ]); - - const { events } = convertAISdkStreamTextToStreamingEvents( - stream as never, - "resp-err-null", - ); - const collected = await collectEvents(events); - - expect(collected).toHaveLength(1); - const errorEvent = collected[0] as StreamingEvent & { - error: { type: string; message: string }; - }; - expect(errorEvent.error.type).toBe("error"); - expect(errorEvent.error.message).toBe("Unknown error"); - }); - }); - - describe("getAccumulatedState", () => { - it("returns correct state after full text lifecycle", async () => { - const stream = mockTextStreamParts([ - { type: "text-start" }, - { type: "text-delta", text: "hello" }, - { type: "text-end" }, - ]); - - const { events, getAccumulatedState } = convertAISdkStreamTextToStreamingEvents( - stream as never, - "resp-state", - ); - await collectEvents(events); - - const state = getAccumulatedState(); - expect(state.outputItems).toHaveLength(1); - expect(state.currentItemId).toBeNull(); - expect(state.currentItemType).toBeNull(); - expect(state.outputIndex).toBe(0); - expect(state.sequenceNumber).toBe(6); - }); - - it("returns empty state for empty stream", async () => { - const stream = mockTextStreamParts([]); - - const { events, getAccumulatedState } = convertAISdkStreamTextToStreamingEvents( - stream as never, - "resp-empty", - ); - await collectEvents(events); - - const state = getAccumulatedState(); - expect(state.outputItems).toHaveLength(0); - expect(state.sequenceNumber).toBe(0); - expect(state.outputIndex).toBe(-1); - expect(state.currentItemId).toBeNull(); - }); - }); - - describe("edge cases", () => { - it("ignores finish part (no events emitted)", async () => { - const stream = mockTextStreamParts([ - { type: "finish", finishReason: "stop" }, - ]); - - const { events } = convertAISdkStreamTextToStreamingEvents( - stream as never, - "resp-finish", - ); - const collected = await collectEvents(events); - - expect(collected).toHaveLength(0); - }); - - it("ignores unknown part types", async () => { - const stream = mockTextStreamParts([ - { type: "some-unknown-type" }, - ]); - - const { events } = convertAISdkStreamTextToStreamingEvents( - stream as never, - "resp-unknown", - ); - const collected = await collectEvents(events); - - expect(collected).toHaveLength(0); - }); - }); -}); diff --git a/packages/api_platform/src/services/ai/__tests__/stream-events.test.ts b/packages/api_platform/src/services/ai/__tests__/stream-events.test.ts index 7bcb3c7..72f9b01 100644 --- a/packages/api_platform/src/services/ai/__tests__/stream-events.test.ts +++ b/packages/api_platform/src/services/ai/__tests__/stream-events.test.ts @@ -33,7 +33,7 @@ describe("streamToEvents", () => { const { events } = streamToEvents( stream as never, - "resp-test-1", + 0, ); const collected = await collectEvents(events); @@ -72,7 +72,7 @@ describe("streamToEvents", () => { const { events } = streamToEvents( stream as never, - "resp-test-2", + 0, ); const collected = await collectEvents(events); @@ -94,7 +94,7 @@ describe("streamToEvents", () => { const { events } = streamToEvents( stream as never, - "resp-idx", + 0, ); const collected = await collectEvents(events); @@ -118,7 +118,7 @@ describe("streamToEvents", () => { const { events } = streamToEvents( stream as never, - "resp-id", + 0, ); const collected = await collectEvents(events); @@ -190,7 +190,7 @@ describe("streamToEvents", () => { const { events } = streamToEvents( stream as never, - "resp-tool-acc", + 0, ); const collected = await collectEvents(events); @@ -210,7 +210,7 @@ describe("streamToEvents", () => { const { events, getAccumulatedState } = streamToEvents( stream as never, - "resp-tool-state", + 0, ); await collectEvents(events); @@ -279,7 +279,7 @@ describe("streamToEvents", () => { const { events } = streamToEvents( stream as never, - "resp-reason-acc", + 0, ); const collected = await collectEvents(events); @@ -297,7 +297,7 @@ describe("streamToEvents", () => { const { events, getAccumulatedState } = streamToEvents( stream as never, - "resp-reason-state", + 0, ); await collectEvents(events); @@ -323,7 +323,7 @@ describe("streamToEvents", () => { const { events } = streamToEvents( stream as never, - "resp-multi", + 0, ); const collected = await collectEvents(events); @@ -349,7 +349,7 @@ describe("streamToEvents", () => { const { events, getAccumulatedState } = streamToEvents( stream as never, - "resp-multi-state", + 0, ); await collectEvents(events); @@ -371,7 +371,7 @@ describe("streamToEvents", () => { const { events } = streamToEvents( stream as never, - "resp-seq", + 0, ); const collected = await collectEvents(events); @@ -397,7 +397,7 @@ describe("streamToEvents", () => { const { events } = streamToEvents( stream as never, - "resp-seq-multi", + 0, ); const collected = await collectEvents(events); @@ -426,7 +426,7 @@ describe("streamToEvents", () => { const { events } = streamToEvents( stream as never, - "resp-err", + 0, ); const collected = await collectEvents(events); @@ -448,7 +448,7 @@ describe("streamToEvents", () => { const { events } = streamToEvents( stream as never, - "resp-err-str", + 0, ); const collected = await collectEvents(events); @@ -471,7 +471,7 @@ describe("streamToEvents", () => { const { events } = streamToEvents( stream as never, - "resp-err-name", + 0, ); const collected = await collectEvents(events); @@ -489,7 +489,7 @@ describe("streamToEvents", () => { const { events } = streamToEvents( stream as never, - "resp-err-null", + 0, ); const collected = await collectEvents(events); @@ -512,7 +512,7 @@ describe("streamToEvents", () => { const { events, getAccumulatedState } = streamToEvents( stream as never, - "resp-state", + 0, ); await collectEvents(events); @@ -529,7 +529,7 @@ describe("streamToEvents", () => { const { events, getAccumulatedState } = streamToEvents( stream as never, - "resp-empty", + 0, ); await collectEvents(events); @@ -549,7 +549,7 @@ describe("streamToEvents", () => { const { events } = streamToEvents( stream as never, - "resp-finish", + 0, ); const collected = await collectEvents(events); @@ -563,7 +563,7 @@ describe("streamToEvents", () => { const { events } = streamToEvents( stream as never, - "resp-unknown", + 0, ); const collected = await collectEvents(events); diff --git a/packages/api_platform/src/services/ai/buildLanguageModelFromResolvedModelAndProvider.ts b/packages/api_platform/src/services/ai/buildLanguageModelFromResolvedModelAndProvider.ts deleted file mode 100644 index e34a2a8..0000000 --- a/packages/api_platform/src/services/ai/buildLanguageModelFromResolvedModelAndProvider.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { Effect } from "effect"; -import { Providers, SUPPORTED_PROVIDERS, type ProviderCredentials } from "common"; -import { AIServiceError } from "."; -import { createAmazonBedrock } from "@ai-sdk/amazon-bedrock"; -import { createOpenAI } from "@ai-sdk/openai"; -import { createAnthropic } from "@ai-sdk/anthropic"; -import type { ResolvedResponse } from "common"; - -const buildInvalidProviderModelError = (provider?: string) => - Effect.fail( - new AIServiceError({ - cause: !provider ? `Empty provider resolved` : `Unsupported provider: ${provider}`, - message: !provider ? `Empty provider resolved` : `Unsupported provider: ${provider}`, - }), - ); - -export const buildLanguageModelFromResolvedModelAndProvider = ( - resolved: ResolvedResponse, - credentials: ProviderCredentials, -) => - Effect.gen(function* () { - const { provider, model } = resolved; - - if (!provider) return yield* buildInvalidProviderModelError(provider); - if (!model) return yield* buildInvalidProviderModelError(provider); - - if (!SUPPORTED_PROVIDERS.includes(provider as Providers)) - return yield* buildInvalidProviderModelError(provider); - - const languageModelProvider = yield* Effect.gen(function* () { - switch (provider as Providers) { - case Providers.AmazonBedrock: { - return createAmazonBedrock(credentials as ProviderCredentials); - } - case Providers.OpenAI: { - return createOpenAI(credentials as ProviderCredentials); - } - case Providers.Anthropic: { - return createAnthropic(credentials as ProviderCredentials); - } - default: - return yield* buildInvalidProviderModelError(provider); - } - }); - - return languageModelProvider(model); - }); diff --git a/packages/api_platform/src/services/ai/convertAISdkGenerateTextMessagesToResponseResourceOutput.ts b/packages/api_platform/src/services/ai/convertAISdkGenerateTextMessagesToResponseResourceOutput.ts deleted file mode 100644 index 4f6c042..0000000 --- a/packages/api_platform/src/services/ai/convertAISdkGenerateTextMessagesToResponseResourceOutput.ts +++ /dev/null @@ -1,357 +0,0 @@ -import type { generateText } from "ai"; -import { Effect } from "effect"; -import type { FunctionCall, FunctionCallOutput, ItemField, Message, ReasoningBody } from "common"; -import { isValidUrl } from "../../utils"; - -export const convertAISdkGenerateTextMessagesToResponseResourceOutput = ( - result: Awaited>, -) => { - type FunctionCallOutputContentPart = Exclude[number]; - type ToolResultContentPart = - | { type: "text"; text: string } - | { type: "media"; mediaType: string; data: string } - | { type: "file-url"; url: string } - | { type: "file-data"; mediaType: string; data: string } - | { type: "image-data"; data: string } - | { type: "image-url"; url: string } - | { type: "file-id"; fileId: string } - | { type: "image-file-id"; fileId: string } - | { type: "custom"; providerOptions: unknown }; - - type ToolResultOutput = - | { type: "text"; value: string } - | { type: "execution-denied"; reason?: string } - | { type: "error-json"; value: unknown } - | { type: "json"; value: unknown } - | { type: "error-text"; value: string } - | { type: "content"; value: ToolResultContentPart[] }; - - const convertToolResultContentPart = ( - outputValue: ToolResultContentPart, - index: number, - ): FunctionCallOutputContentPart => { - switch (outputValue.type) { - case "text": - return { - type: "input_text", - text: outputValue.text, - } satisfies FunctionCallOutputContentPart; - case "media": { - if (outputValue.mediaType.startsWith("image/")) { - return { - type: "input_image", - image_url: isValidUrl(outputValue.data) - ? outputValue.data - : `data:image/png;base64,${outputValue.data}`, - detail: "auto", - } satisfies FunctionCallOutputContentPart; - } - return { - type: "input_file", - filename: `file-${index}`, - file_url: isValidUrl(outputValue.data) - ? outputValue.data - : `data:${outputValue.mediaType};base64,${outputValue.data}`, - } satisfies FunctionCallOutputContentPart; - } - case "file-url": - return { - type: "input_file", - filename: `file-${index}`, - file_url: outputValue.url, - } satisfies FunctionCallOutputContentPart; - case "file-data": - return { - type: "input_file", - filename: `file-${index}`, - file_url: `data:${outputValue.mediaType};base64,${outputValue.data}`, - } satisfies FunctionCallOutputContentPart; - case "image-data": - return { - type: "input_image", - image_url: `data:image/png;base64,${outputValue.data}`, - detail: "auto", - } satisfies FunctionCallOutputContentPart; - case "image-url": - return { - type: "input_image", - image_url: outputValue.url, - detail: "auto", - } satisfies FunctionCallOutputContentPart; - case "file-id": - case "image-file-id": - return { - type: "input_text", - text: JSON.stringify(outputValue.fileId), - } satisfies FunctionCallOutputContentPart; - case "custom": - return { - type: "input_text", - text: JSON.stringify(outputValue.providerOptions), - } satisfies FunctionCallOutputContentPart; - } - }; - - const convertToolResultOutput = (output: ToolResultOutput): FunctionCallOutput["output"] => { - switch (output.type) { - case "text": - return output.value; - case "execution-denied": - return `Tool execution denied "${output.reason || "NO REASON PROVIDED"}"`; - case "error-json": - case "json": - return JSON.stringify(output.value); - case "error-text": - return `Tool execution resulted in error: "${output.value}"`; - case "content": { - return output.value.map((outputValue, index) => - convertToolResultContentPart(outputValue, index), - ) satisfies FunctionCallOutput["output"]; - } - } - }; - - const getItemIdFromProviderOptions = (providerOptions: unknown, fallbackId: string) => { - if (!providerOptions || typeof providerOptions !== "object") { - return fallbackId; - } - - const itemEntry = Object.entries(providerOptions).find( - ([_, metadata]) => typeof metadata === "object" && metadata !== null && "itemId" in metadata, - )?.[1] as { itemId?: string } | undefined; - - return itemEntry?.itemId ?? fallbackId; - }; - - const getEncryptedContentFromProviderOptions = ( - providerOptions: unknown, - ): string | undefined => { - if (!providerOptions || typeof providerOptions !== "object") return undefined; - const entry = Object.entries(providerOptions).find( - ([_, metadata]) => - typeof metadata === "object" && - metadata !== null && - "reasoningEncryptedContent" in metadata, - )?.[1] as { reasoningEncryptedContent?: string } | undefined; - return entry?.reasoningEncryptedContent; - }; - - const messagesAsOutput: ItemField[] = result.response.messages.flatMap((message, indx) => { - const messageRole = message.role as Message["role"] | "tool"; - - switch (messageRole) { - case "tool": { - type ToolMessageContentItem = - | { - type: "tool-result"; - toolCallId: string; - output: ToolResultOutput; - providerOptions?: unknown; - } - | { type: "tool-approval-response"; approvalId: string }; - - const content = Array.isArray(message.content) - ? (message.content as ToolMessageContentItem[]) - : []; - - return content.flatMap((c) => { - switch (c.type) { - case "tool-result": { - return [ - { - type: "function_call_output", - id: getItemIdFromProviderOptions(c.providerOptions, c.toolCallId), - status: "completed", - output: convertToolResultOutput(c.output as ToolResultOutput), - call_id: c.toolCallId, - } satisfies FunctionCallOutput, - ]; - } - case "tool-approval-response": { - return [{ type: "function_call", call_id: c.approvalId } as ItemField]; - } - default: - return []; - } - }); - } - case "user": - case "system": - case "developer": - return []; - case "assistant": { - const content = message.content; - if (typeof content === "string") { - return { - type: "message", - id: `message-${indx}`, - status: "completed", - role: "assistant", - content: [ - { - type: "output_text", - text: content, - annotations: [], - logprobs: [], - }, - ], - } satisfies Message; - } - return content.flatMap((contentItem): ItemField[] => { - switch (contentItem.type) { - case "text": { - return [ - { - type: "message", - id: getItemIdFromProviderOptions(contentItem.providerOptions, `message-${indx}`), - status: "completed", - role: "assistant", - content: [ - { - type: "output_text", - text: contentItem.text, - annotations: [], - logprobs: [], - }, - ], - } satisfies Message, - ]; - } - case "reasoning": { - const encryptedContent = getEncryptedContentFromProviderOptions( - contentItem.providerOptions, - ); - return [ - { - type: "reasoning", - id: getItemIdFromProviderOptions( - contentItem.providerOptions, - `reasoning-${indx}`, - ), - summary: [ - { - type: "summary_text", - text: contentItem.text, - }, - ], - ...(encryptedContent ? { encrypted_content: encryptedContent } : {}), - } satisfies ReasoningBody, - ]; - } - case "file": { - if (contentItem.mediaType.startsWith("image/")) { - return [ - { - type: "message", - id: getItemIdFromProviderOptions( - contentItem.providerOptions, - `message-${indx}`, - ), - status: "completed", - role: "assistant", - content: [ - { - type: "input_image", - image_url: - contentItem.data instanceof URL - ? String(contentItem.data) - : `data:${contentItem.mediaType};base64,${contentItem.data}`, - detail: "auto", - }, - ], - } satisfies Message, - ]; - } - - if (contentItem.mediaType.startsWith("video/")) { - return [ - { - type: "message", - id: getItemIdFromProviderOptions( - contentItem.providerOptions, - `message-${indx}`, - ), - status: "completed", - role: "assistant", - content: [ - { - type: "input_video", - video_url: - contentItem.data instanceof URL - ? String(contentItem.data) - : `data:${contentItem.mediaType};base64,${contentItem.data}`, - }, - ], - } satisfies Message, - ]; - } - - return [ - { - type: "message", - id: `message-${indx}`, - status: "completed", - role: "assistant", - content: [ - { - type: "input_file", - file_url: - contentItem.data instanceof URL - ? String(contentItem.data) - : `data:${contentItem.mediaType};base64,${contentItem.data}`, - }, - ], - } satisfies Message, - ]; - } - case "tool-call": { - return [ - { - type: "function_call", - id: getItemIdFromProviderOptions( - contentItem.providerOptions, - contentItem.toolCallId, - ), - status: "completed", - call_id: contentItem.toolCallId, - name: contentItem.toolName, - arguments: - typeof contentItem.input === "string" - ? contentItem.input - : JSON.stringify(contentItem.input), - } satisfies FunctionCall, - ]; - } - case "tool-approval-request": { - return [ - { - type: "function_call", - id: contentItem.approvalId, - status: "in_progress", - call_id: contentItem.toolCallId, - name: `tool-approval-${contentItem.toolCallId}`, - arguments: "", - } satisfies FunctionCall, - ]; - } - case "tool-result": { - return [ - { - type: "function_call_output", - id: contentItem.toolCallId, - status: "completed", - output: convertToolResultOutput(contentItem.output as ToolResultOutput), - call_id: "", - } satisfies FunctionCallOutput, - ]; - } - default: - return []; - } - }); - } - } - }); - - return Effect.succeed(messagesAsOutput); -}; diff --git a/packages/api_platform/src/services/ai/convertAISdkGenerateTextResultToResponseResource.ts b/packages/api_platform/src/services/ai/convertAISdkGenerateTextResultToResponseResource.ts deleted file mode 100644 index 6d8a60b..0000000 --- a/packages/api_platform/src/services/ai/convertAISdkGenerateTextResultToResponseResource.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { Effect } from "effect"; -import type { CreateResponseBody, ResponseResource } from "common"; -import type { generateText } from "ai"; -import { - DEFAULT_BACKGROUND, - DEFAULT_FREQUENCY_PENALTY, - DEFAULT_PARALLEL_TOOL_CALLS, - DEFAULT_PRESENCE_PENALTY, - DEFAULT_SERVICE_TIER, - DEFAULT_STORE, - DEFAULT_TEMPERATURE, - DEFAULT_TOP_LOGPROBS, - DEFAULT_TOP_P, - DEFAULT_TRUNCATION, -} from "./consts"; -import { - resolveTools, - resolveToolChoice, - resolveTextFormat, -} from "./createResponseBodyFieldsToResponseResourceFieldsResolvers"; -import { convertAISdkGenerateTextMessagesToResponseResourceOutput } from "./convertAISdkGenerateTextMessagesToResponseResourceOutput"; -import type { ResolvedResponse } from "common"; - -export const convertAISdkGenerateTextResultToResponseResource = ({ - result, - createdAt, - resolvedModelAndProvider, - createResponseBody, -}: { - result: Awaited>; - createdAt: number; - resolvedModelAndProvider: ResolvedResponse; - createResponseBody: CreateResponseBody; -}) => - Effect.gen(function* () { - return { - object: "response", - id: crypto.randomUUID(), - created_at: createdAt, - completed_at: Date.now(), - status: "completed", - incomplete_details: null, - model: `${resolvedModelAndProvider.provider}/${resolvedModelAndProvider.model}`, - previous_response_id: createResponseBody.previous_response_id ?? null, - instructions: createResponseBody.instructions ?? null, - output: yield* convertAISdkGenerateTextMessagesToResponseResourceOutput(result), - text: resolveTextFormat(createResponseBody.text), - top_logprobs: createResponseBody.top_logprobs ?? DEFAULT_TOP_LOGPROBS, - reasoning: createResponseBody.reasoning - ? { - effort: createResponseBody.reasoning.effort ?? null, - summary: createResponseBody.reasoning.summary ?? null, - } - : null, - error: null, - tools: resolveTools(createResponseBody.tools), - tool_choice: resolveToolChoice(createResponseBody.tool_choice), - truncation: createResponseBody.truncation ?? DEFAULT_TRUNCATION, - parallel_tool_calls: createResponseBody.parallel_tool_calls ?? DEFAULT_PARALLEL_TOOL_CALLS, - top_p: createResponseBody.top_p ?? DEFAULT_TOP_P, - presence_penalty: createResponseBody.presence_penalty ?? DEFAULT_PRESENCE_PENALTY, - frequency_penalty: createResponseBody.frequency_penalty ?? DEFAULT_FREQUENCY_PENALTY, - temperature: createResponseBody.temperature ?? DEFAULT_TEMPERATURE, - usage: { - input_tokens: result.totalUsage.inputTokens ?? 0, - output_tokens: result.totalUsage.outputTokens ?? 0, - input_tokens_details: { - cached_tokens: result.totalUsage.inputTokenDetails?.cacheWriteTokens ?? 0, - }, - output_tokens_details: { - reasoning_tokens: result.totalUsage.outputTokenDetails?.reasoningTokens ?? 0, - }, - total_tokens: result.totalUsage.totalTokens ?? 0, - }, - max_output_tokens: createResponseBody.max_output_tokens ?? null, - max_tool_calls: createResponseBody.max_tool_calls ?? null, - store: createResponseBody.store ?? DEFAULT_STORE, - background: createResponseBody.background ?? DEFAULT_BACKGROUND, - service_tier: createResponseBody.service_tier ?? DEFAULT_SERVICE_TIER, - metadata: createResponseBody.metadata ?? null, - safety_identifier: createResponseBody.safety_identifier ?? null, - prompt_cache_key: createResponseBody.prompt_cache_key ?? null, - } satisfies ResponseResource; - }); diff --git a/packages/api_platform/src/services/ai/convertAISdkStreamTextToStreamingEvents.ts b/packages/api_platform/src/services/ai/convertAISdkStreamTextToStreamingEvents.ts deleted file mode 100644 index 8e2d907..0000000 --- a/packages/api_platform/src/services/ai/convertAISdkStreamTextToStreamingEvents.ts +++ /dev/null @@ -1,445 +0,0 @@ -import type { TextStreamPart, ToolSet } from "ai"; -import type { FunctionCall, ItemField, Message, ReasoningBody, StreamingEvent } from "common"; - -export type AccumulatedState = { - sequenceNumber: number; - currentItemId: string | null; - outputIndex: number; - contentIndex: number; - accumulatedText: string; - outputItems: ItemField[]; - currentItemType: "text" | "reasoning" | "tool-input" | null; - currentToolCallId: string | null; - currentToolName: string | null; -}; - -type ToolInputPart = { - toolCallId?: string; - toolName?: string; - delta?: string; - text?: string; - argsTextDelta?: string; -}; - -const getTextDelta = (part: ToolInputPart): string => - part.delta ?? part.text ?? part.argsTextDelta ?? ""; - -export const convertAISdkStreamTextToStreamingEvents = ( - fullStream: AsyncIterable>, - startingSequenceNumber = 0, -) => { - let sequenceNumber = startingSequenceNumber; - let currentItemId: string | null = null; - let outputIndex = -1; - let contentIndex = 0; - let accumulatedText = ""; - let currentItemType: AccumulatedState["currentItemType"] = null; - let currentToolCallId: string | null = null; - let currentToolName: string | null = null; - const outputItems: ItemField[] = []; - - const nextSequenceNumber = () => { - const current = sequenceNumber; - sequenceNumber += 1; - return current; - }; - - const startNewItem = (type: AccumulatedState["currentItemType"]) => { - currentItemId = crypto.randomUUID(); - outputIndex += 1; - contentIndex = 0; - accumulatedText = ""; - currentItemType = type; - }; - - const finishItem = () => { - currentItemId = null; - currentItemType = null; - currentToolCallId = null; - currentToolName = null; - }; - - const getAccumulatedState = (): AccumulatedState => ({ - sequenceNumber, - currentItemId, - outputIndex, - contentIndex, - accumulatedText, - outputItems, - currentItemType, - currentToolCallId, - currentToolName, - }); - - const events = (async function* () { - for await (const part of fullStream) { - try { - switch (part.type) { - case "text-start": { - startNewItem("text"); - - if (!currentItemId) break; - - const addedEvent: StreamingEvent = { - type: "response.output_item.added", - sequence_number: nextSequenceNumber(), - output_index: outputIndex, - item: { - id: currentItemId, - status: "in_progress", - role: "assistant", - content: [], - }, - }; - - const contentAddedEvent: StreamingEvent = { - type: "response.content_part.added", - sequence_number: nextSequenceNumber(), - item_id: currentItemId, - output_index: outputIndex, - content_index: contentIndex, - part: { - type: "output_text", - text: "", - annotations: [], - logprobs: [], - }, - }; - - contentIndex += 1; - yield [addedEvent, contentAddedEvent]; - break; - } - case "text-delta": { - if (!currentItemId || currentItemType !== "text") break; - const delta = (part as ToolInputPart).text ?? ""; - accumulatedText += delta; - const deltaEvent: StreamingEvent = { - type: "response.output_text.delta", - sequence_number: nextSequenceNumber(), - item_id: currentItemId, - output_index: outputIndex, - content_index: Math.max(contentIndex - 1, 0), - delta, - logprobs: [], - }; - yield [deltaEvent]; - break; - } - case "text-end": { - if (!currentItemId || currentItemType !== "text") break; - - const textDoneEvent: StreamingEvent = { - type: "response.output_text.done", - sequence_number: nextSequenceNumber(), - item_id: currentItemId, - output_index: outputIndex, - content_index: Math.max(contentIndex - 1, 0), - text: accumulatedText, - logprobs: [], - }; - - const contentDoneEvent: StreamingEvent = { - type: "response.content_part.done", - sequence_number: nextSequenceNumber(), - item_id: currentItemId, - output_index: outputIndex, - content_index: Math.max(contentIndex - 1, 0), - part: { - type: "output_text", - text: accumulatedText, - annotations: [], - logprobs: [], - }, - }; - - const completedMessageItem = { - id: currentItemId, - status: "completed" as const, - role: "assistant" as const, - content: [ - { - type: "output_text" as const, - text: accumulatedText, - annotations: [] as never[], - logprobs: [] as never[], - }, - ], - }; - - const outputDoneEvent: StreamingEvent = { - type: "response.output_item.done", - sequence_number: nextSequenceNumber(), - output_index: outputIndex, - item: completedMessageItem, - }; - - outputItems.push({ - type: "message", - ...completedMessageItem, - } satisfies Message); - - yield [textDoneEvent, contentDoneEvent, outputDoneEvent]; - finishItem(); - break; - } - case "reasoning-start": { - startNewItem("reasoning"); - - if (!currentItemId) break; - - const addedEvent: StreamingEvent = { - type: "response.output_item.added", - sequence_number: nextSequenceNumber(), - output_index: outputIndex, - item: { - id: currentItemId, - summary: [], - }, - }; - - const contentAddedEvent: StreamingEvent = { - type: "response.content_part.added", - sequence_number: nextSequenceNumber(), - item_id: currentItemId, - output_index: outputIndex, - content_index: contentIndex, - part: { - type: "reasoning", - text: "", - }, - }; - - contentIndex += 1; - yield [addedEvent, contentAddedEvent]; - break; - } - case "reasoning-delta": { - if (!currentItemId || currentItemType !== "reasoning") break; - const delta = (part as ToolInputPart).text ?? ""; - accumulatedText += delta; - const deltaEvent: StreamingEvent = { - type: "response.reasoning.delta", - sequence_number: nextSequenceNumber(), - item_id: currentItemId, - output_index: outputIndex, - content_index: Math.max(contentIndex - 1, 0), - delta, - }; - yield [deltaEvent]; - break; - } - case "reasoning-end": { - if (!currentItemId || currentItemType !== "reasoning") break; - - const reasoningDoneEvent: StreamingEvent = { - type: "response.reasoning.done", - sequence_number: nextSequenceNumber(), - item_id: currentItemId, - output_index: outputIndex, - content_index: Math.max(contentIndex - 1, 0), - text: accumulatedText, - }; - - const contentDoneEvent: StreamingEvent = { - type: "response.content_part.done", - sequence_number: nextSequenceNumber(), - item_id: currentItemId, - output_index: outputIndex, - content_index: Math.max(contentIndex - 1, 0), - part: { - type: "reasoning", - text: accumulatedText, - }, - }; - - const completedReasoningItem = { - id: currentItemId, - summary: [] as never[], - content: [{ type: "reasoning" as const, text: accumulatedText }], - }; - - const outputDoneEvent: StreamingEvent = { - type: "response.output_item.done", - sequence_number: nextSequenceNumber(), - output_index: outputIndex, - item: completedReasoningItem, - }; - - outputItems.push({ - type: "reasoning", - ...completedReasoningItem, - } satisfies ReasoningBody); - - yield [reasoningDoneEvent, contentDoneEvent, outputDoneEvent]; - finishItem(); - break; - } - case "tool-input-start": { - startNewItem("tool-input"); - const { toolCallId, toolName } = part as ToolInputPart; - currentToolCallId = toolCallId ?? null; - currentToolName = toolName ?? null; - - if (!currentItemId) break; - - const addedEvent: StreamingEvent = { - type: "response.output_item.added", - sequence_number: nextSequenceNumber(), - output_index: outputIndex, - item: { - id: currentItemId, - call_id: currentToolCallId ?? "", - name: currentToolName ?? "", - arguments: "", - status: "in_progress" as const, - }, - }; - - const contentAddedEvent: StreamingEvent = { - type: "response.content_part.added", - sequence_number: nextSequenceNumber(), - item_id: currentItemId, - output_index: outputIndex, - content_index: contentIndex, - part: { - type: "input_text", - text: "", - }, - }; - - contentIndex += 1; - yield [addedEvent, contentAddedEvent]; - break; - } - case "tool-input-delta": { - if (!currentItemId || currentItemType !== "tool-input") break; - const delta = getTextDelta(part as ToolInputPart); - accumulatedText += delta; - const deltaEvent: StreamingEvent = { - type: "response.function_call_arguments.delta", - sequence_number: nextSequenceNumber(), - item_id: currentItemId, - output_index: outputIndex, - delta, - }; - yield [deltaEvent]; - break; - } - case "tool-input-end": { - if (!currentItemId || currentItemType !== "tool-input") break; - - const argumentsDoneEvent: StreamingEvent = { - type: "response.function_call_arguments.done", - sequence_number: nextSequenceNumber(), - item_id: currentItemId, - output_index: outputIndex, - arguments: accumulatedText, - }; - - const contentDoneEvent: StreamingEvent = { - type: "response.content_part.done", - sequence_number: nextSequenceNumber(), - item_id: currentItemId, - output_index: outputIndex, - content_index: Math.max(contentIndex - 1, 0), - part: { - type: "input_text", - text: accumulatedText, - }, - }; - - const completedFunctionCallItem = { - id: currentItemId, - status: "completed" as const, - call_id: currentToolCallId ?? "", - name: currentToolName ?? "", - arguments: accumulatedText, - }; - - const outputDoneEvent: StreamingEvent = { - type: "response.output_item.done", - sequence_number: nextSequenceNumber(), - output_index: outputIndex, - item: completedFunctionCallItem, - }; - - outputItems.push({ - type: "function_call", - ...completedFunctionCallItem, - } satisfies FunctionCall); - - yield [argumentsDoneEvent, contentDoneEvent, outputDoneEvent]; - finishItem(); - break; - } - case "error": { - const errorValue = (part as { error?: unknown }).error; - const errorObject = - typeof errorValue === "object" && errorValue !== null - ? (errorValue as Record) - : undefined; - const errorEvent: StreamingEvent = { - type: "error", - sequence_number: nextSequenceNumber(), - error: { - type: (typeof errorObject?.type === "string" - ? errorObject?.type - : typeof errorObject?.name === "string" - ? errorObject?.name - : "error") as string, - code: typeof errorObject?.code === "string" ? errorObject?.code : null, - message: - typeof errorObject?.message === "string" - ? errorObject?.message - : typeof errorValue === "string" - ? errorValue - : "Unknown error", - param: typeof errorObject?.param === "string" ? errorObject?.param : null, - ...(typeof errorObject?.headers === "object" && errorObject?.headers !== null - ? { headers: errorObject?.headers as Record } - : {}), - }, - }; - yield [errorEvent]; - break; - } - case "finish": { - break; - } - default: { - break; - } - } - } catch (e) { - const errorObject = - typeof e === "object" && e !== null ? (e as Record) : undefined; - const errorEvent: StreamingEvent = { - type: "error", - sequence_number: nextSequenceNumber(), - error: { - type: (typeof errorObject?.type === "string" - ? errorObject?.type - : typeof errorObject?.name === "string" - ? errorObject?.name - : "error") as string, - code: typeof errorObject?.code === "string" ? errorObject?.code : null, - message: - typeof errorObject?.message === "string" - ? errorObject?.message - : typeof e === "string" - ? e - : "Unknown error", - param: typeof errorObject?.param === "string" ? errorObject?.param : null, - ...(typeof errorObject?.headers === "object" && errorObject?.headers !== null - ? { headers: errorObject?.headers as Record } - : {}), - }, - }; - yield [errorEvent]; - } - } - })(); - - return { events, getAccumulatedState }; -}; diff --git a/packages/api_platform/src/services/ai/convertAPICallErrorToResponseResource.ts b/packages/api_platform/src/services/ai/convertAPICallErrorToResponseResource.ts deleted file mode 100644 index 1c4e7a6..0000000 --- a/packages/api_platform/src/services/ai/convertAPICallErrorToResponseResource.ts +++ /dev/null @@ -1,69 +0,0 @@ -import type { APICallError } from "ai"; -import { Effect } from "effect"; -import type { CreateResponseBody, ResponseResource } from "common"; -import { - DEFAULT_BACKGROUND, - DEFAULT_FREQUENCY_PENALTY, - DEFAULT_PARALLEL_TOOL_CALLS, - DEFAULT_PRESENCE_PENALTY, - DEFAULT_SERVICE_TIER, - DEFAULT_STORE, - DEFAULT_TEMPERATURE, - DEFAULT_TOP_LOGPROBS, - DEFAULT_TOP_P, - DEFAULT_TRUNCATION, -} from "./consts"; -import { - resolveTools, - resolveToolChoice, - resolveTextFormat, -} from "./createResponseBodyFieldsToResponseResourceFieldsResolvers"; -import type { ResolvedResponse } from "common"; - -export const convertAPICallErrorToResponseResource = ({ - result, - createResponseBody, - createdAt, - resolvedModelAndProvider, -}: { - result: APICallError; - createResponseBody: CreateResponseBody; - createdAt: number; - resolvedModelAndProvider: ResolvedResponse; -}): Effect.Effect => - Effect.succeed({ - id: crypto.randomUUID(), - created_at: createdAt, - model: `${resolvedModelAndProvider.provider}/${resolvedModelAndProvider.model}`, - completed_at: Date.now(), - error: { - code: String(result.statusCode ?? 500), - message: result.message, - }, - object: "response", - background: createResponseBody.background ?? DEFAULT_BACKGROUND, - status: "error", - incomplete_details: null, - previous_response_id: createResponseBody.previous_response_id ?? null, - instructions: createResponseBody.instructions ?? null, - output: [], - tools: resolveTools(createResponseBody.tools), - tool_choice: resolveToolChoice(createResponseBody.tool_choice), - truncation: createResponseBody.truncation ?? DEFAULT_TRUNCATION, - text: resolveTextFormat(createResponseBody.text), - top_p: createResponseBody.top_p ?? DEFAULT_TOP_P, - presence_penalty: createResponseBody.presence_penalty ?? DEFAULT_PRESENCE_PENALTY, - frequency_penalty: createResponseBody.frequency_penalty ?? DEFAULT_FREQUENCY_PENALTY, - top_logprobs: createResponseBody.top_logprobs ?? DEFAULT_TOP_LOGPROBS, - temperature: createResponseBody.temperature ?? DEFAULT_TEMPERATURE, - reasoning: null, - usage: null, - max_output_tokens: createResponseBody.max_output_tokens ?? null, - max_tool_calls: createResponseBody.max_tool_calls ?? null, - store: createResponseBody.store ?? DEFAULT_STORE, - service_tier: createResponseBody.service_tier ?? DEFAULT_SERVICE_TIER, - metadata: createResponseBody.metadata ?? null, - safety_identifier: createResponseBody.safety_identifier ?? null, - prompt_cache_key: createResponseBody.prompt_cache_key ?? null, - parallel_tool_calls: createResponseBody.parallel_tool_calls ?? DEFAULT_PARALLEL_TOOL_CALLS, - } satisfies ResponseResource); diff --git a/packages/api_platform/src/services/ai/createResponseBodyFieldsToResponseResourceFieldsResolvers.ts b/packages/api_platform/src/services/ai/createResponseBodyFieldsToResponseResourceFieldsResolvers.ts deleted file mode 100644 index e794b23..0000000 --- a/packages/api_platform/src/services/ai/createResponseBodyFieldsToResponseResourceFieldsResolvers.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { CreateResponseBody, ResponseResource } from "common"; - -export const resolveToolChoice = ( - tc: CreateResponseBody["tool_choice"], -): ResponseResource["tool_choice"] => { - if (!tc) return "none"; - if (typeof tc === "string") return tc; - if (tc.type === "function") return tc; - return { ...tc, mode: tc.mode ?? "auto" }; -}; - -export const resolveTools = (tools: CreateResponseBody["tools"]): ResponseResource["tools"] => - tools?.map((t) => ({ - type: "function" as const, - name: t.name, - description: t.description ?? null, - parameters: t.parameters ?? null, - strict: t.strict ?? null, - })) ?? []; - -export const resolveReasoning = ( - reasoning: CreateResponseBody["reasoning"], -): ResponseResource["reasoning"] => - reasoning ? { effort: reasoning.effort ?? null, summary: reasoning.summary ?? null } : null; - -export const resolveTextFormat = ( - text: CreateResponseBody["text"], -): ResponseResource["text"] => { - const format = text?.format; - if (!format || format.type === "text") return { format: { type: "text" as const } }; - return { - format: { - type: "json_schema" as const, - name: format.name ?? "json_schema", - description: format.description ?? null, - schema: format.schema ?? null, - strict: format.strict ?? false, - }, - }; -}; diff --git a/packages/api_platform/src/services/ai/field-resolvers.ts b/packages/api_platform/src/services/ai/field-resolvers.ts index e794b23..5fb51f7 100644 --- a/packages/api_platform/src/services/ai/field-resolvers.ts +++ b/packages/api_platform/src/services/ai/field-resolvers.ts @@ -18,11 +18,6 @@ export const resolveTools = (tools: CreateResponseBody["tools"]): ResponseResour strict: t.strict ?? null, })) ?? []; -export const resolveReasoning = ( - reasoning: CreateResponseBody["reasoning"], -): ResponseResource["reasoning"] => - reasoning ? { effort: reasoning.effort ?? null, summary: reasoning.summary ?? null } : null; - export const resolveTextFormat = ( text: CreateResponseBody["text"], ): ResponseResource["text"] => { diff --git a/packages/api_platform/src/services/ai/index.ts b/packages/api_platform/src/services/ai/index.ts index 4d6550c..0f803ca 100644 --- a/packages/api_platform/src/services/ai/index.ts +++ b/packages/api_platform/src/services/ai/index.ts @@ -1,11 +1,10 @@ import type { CreateResponseBody, ResolvedResponse, ResponseResource, Providers } from "common"; -import type { Transaction } from "ledger"; +import type { Transaction } from "@enfinyte/services"; import { AISDKError, type TextStreamPart, type ToolSet } from "ai"; import { APICallError, generateText, streamText } from "ai"; import { Effect, Data, Either } from "effect"; -import { LedgerService } from "ledger"; -import { ResolverService } from "resolver"; +import { LedgerService, ResolverService } from "@enfinyte/services"; import type { RequestParams } from "../request-context"; @@ -413,9 +412,9 @@ export const buildTransaction = (opts: { input_tokens: inputTokens, reasoning_tokens: reasoningTokens, output_tokens: outputTokens, - input_cost_usd: inputTokens != null && cost ? inputTokens * cost.input : null, - reasoning_cost_usd: reasoningTokens != null && cost ? reasoningTokens * cost.input : null, - output_cost_usd: outputTokens != null && cost ? outputTokens * cost.output : null, + input_cost_usd: inputTokens != null && cost ? (inputTokens * cost.input) / 1_000_000 : null, + reasoning_cost_usd: reasoningTokens != null && cost ? (reasoningTokens * cost.input) / 1_000_000 : null, + output_cost_usd: outputTokens != null && cost ? (outputTokens * cost.output) / 1_000_000 : null, http_status_code: httpStatusCode, error_type: errorType, is_streaming: isStreaming, diff --git a/packages/api_platform/src/services/ai/responseFieldsToAISDKGenerateTextCallSettingsAdapters.ts b/packages/api_platform/src/services/ai/responseFieldsToAISDKGenerateTextCallSettingsAdapters.ts deleted file mode 100644 index ed3faf1..0000000 --- a/packages/api_platform/src/services/ai/responseFieldsToAISDKGenerateTextCallSettingsAdapters.ts +++ /dev/null @@ -1,482 +0,0 @@ -import { Effect } from "effect"; -import type { CreateResponseBody, CreateResponseBodyInputItem } from "common"; -import type { - FilePart, - ImagePart, - ModelMessage, - SystemModelMessage, - TextPart, - ToolResultPart, -} from "ai"; -import { jsonSchema, Output } from "ai"; -import type { ToolSet } from "ai"; -import { AIServiceError } from "."; -import { isNotNullable } from "effect/Predicate"; -import { detectMimeTypeFromBase64EncodedString, detectMimeTypeFromURL } from "../../utils"; - -export const convertCreateResponseBodyInputFieldToCallSettingsMessages = ( - createResponseBody: CreateResponseBody, -) => - Effect.gen(function* () { - const { input, instructions } = createResponseBody; - - if (!input) { - return yield* new AIServiceError({ - message: "Input field is required for message-based models.", - }); - } - - const instructionsAsSystemMessage: SystemModelMessage[] = instructions - ? [ - { - role: "system", - content: instructions, - }, - ] - : []; - - if (typeof input === "string") { - return yield* Effect.succeed([ - ...instructionsAsSystemMessage, - { - role: "user", - content: input, - }, - ] satisfies ModelMessage[] as ModelMessage[]); - } - - const inputItemsAsModelMessage = yield* Effect.all(input.map(convertInputItemToModelMessage)); - - return yield* Effect.succeed( - [...instructionsAsSystemMessage, ...inputItemsAsModelMessage.flat()].filter( - isNotNullable, - ) satisfies ModelMessage[] as ModelMessage[], - ); - }); - -const convertInputItemToModelMessage = ( - createResponseBodyInputItem: CreateResponseBodyInputItem, -): Effect.Effect => - Effect.gen(function* () { - switch (createResponseBodyInputItem.type) { - case "message": { - const role = createResponseBodyInputItem.role; - const content = createResponseBodyInputItem.content; - - switch (role) { - case "system": - case "developer": { - const providerOptions = - role === "developer" - ? { - providerOptions: { - openai: { systemMessageMode: "developer" }, - }, - } - : {}; - - if (typeof content === "string") - return [ - { - role: "system", - content, - ...providerOptions, - }, - ] satisfies ModelMessage[]; - else - return content - .filter((contentItem) => contentItem.type === "input_text") - .map((contentItem) => ({ - role: "system", - content: contentItem.text, - ...providerOptions, - })) satisfies ModelMessage[]; - } - case "user": { - if (typeof content === "string") { - return [ - { - role: "user", - content, - }, - ] satisfies ModelMessage[]; - } else { - const parts = yield* Effect.all( - content.map((contentItem) => - Effect.gen(function* () { - switch (contentItem.type) { - case "input_text": - return { - type: "text", - text: contentItem.text, - } satisfies TextPart; - case "input_image": - if (!contentItem.image_url) return; - return { - type: "image", - image: new URL(contentItem.image_url), - providerOptions: { - openai: { - imageDetail: contentItem.detail, - }, - }, - } satisfies ImagePart; - case "input_file": { - if (!contentItem.file_data && !contentItem.file_url) return; - - const mediaType = contentItem.file_url - ? yield* detectMimeTypeFromURL(contentItem.file_url) - : contentItem.file_data - ? yield* detectMimeTypeFromBase64EncodedString(contentItem.file_data) - : "application/octet-stream"; - - return { - type: "file", - ...(contentItem.filename ? { filename: contentItem.filename } : {}), - data: contentItem.file_url - ? new URL(contentItem.file_url) - : contentItem.file_data - ? contentItem.file_data - : "<<<<<>>>>>", - mediaType, - } satisfies FilePart; - } - } - }), - ), - ); - return [ - { - role: "user", - content: parts.filter(isNotNullable), - }, - ] satisfies ModelMessage[]; - } - } - case "assistant": { - if (typeof content === "string") { - return [ - { - role: "assistant", - content, - }, - ] satisfies ModelMessage[]; - } else { - const parts = yield* Effect.all( - content.map((contentItem) => - Effect.gen(function* () { - switch (contentItem.type) { - case "input_text": - case "output_text": - return { - type: "text", - text: contentItem.text, - } satisfies TextPart; - case "input_image": - if (!contentItem.image_url) return; - return { - type: "file", - data: new URL(contentItem.image_url), - mediaType: "image/png", - providerOptions: { - openai: { - imageDetail: contentItem.detail, - }, - }, - } satisfies FilePart; - case "input_file": { - if (!contentItem.file_data && !contentItem.file_url) return; - - const mediaType = contentItem.file_url - ? yield* detectMimeTypeFromURL(contentItem.file_url) - : contentItem.file_data - ? yield* detectMimeTypeFromBase64EncodedString(contentItem.file_data) - : "application/octet-stream"; - - return { - type: "file", - ...(contentItem.filename ? { filename: contentItem.filename } : {}), - data: contentItem.file_url - ? new URL(contentItem.file_url) - : contentItem.file_data - ? contentItem.file_data - : "<<<<<>>>>>", - mediaType, - } satisfies FilePart; - } - } - }), - ), - ); - - return [ - { - role: "assistant", - content: parts.filter(isNotNullable), - }, - ] satisfies ModelMessage[]; - } - } - } - } - case "reasoning": { - return [ - { - role: "assistant", - content: createResponseBodyInputItem.summary.map((summaryItem) => ({ - type: "reasoning", - text: summaryItem.text, - })), - providerOptions: { - openai: { - itemId: createResponseBodyInputItem.id, - reasoningEncryptedContent: createResponseBodyInputItem.encrypted_content, - }, - }, - }, - ] satisfies ModelMessage[]; - } - - case "function_call": { - const parsedArguments = yield* Effect.try({ - try: () => JSON.parse(createResponseBodyInputItem.arguments), - catch: (error) => - new AIServiceError({ - message: `Failed to parse function_call arguments: ${error}`, - cause: error, - }), - }); - - return [ - { - role: "assistant", - content: [ - { - type: "tool-call", - toolCallId: createResponseBodyInputItem.call_id, - toolName: createResponseBodyInputItem.name, - input: parsedArguments, - providerExecuted: createResponseBodyInputItem.status === "completed", - }, - ], - providerOptions: { - openai: { - itemId: createResponseBodyInputItem.id, - }, - }, - }, - ] satisfies ModelMessage[]; - } - - case "function_call_output": { - return [ - { - role: "tool", - content: [ - { - type: "tool-result", - toolCallId: createResponseBodyInputItem.call_id, - toolName: crypto.randomUUID(), - output: yield* (() => - Effect.gen(function* () { - const output = createResponseBodyInputItem.output; - - if (typeof output === "string") { - return { - type: "text", - value: output, - } satisfies ToolResultPart["output"]; - } - - return { - type: "content", - value: (yield* Effect.all( - output.map((outputItem) => - Effect.gen(function* () { - switch (outputItem.type) { - case "input_text": - return { - type: "text" as const, - text: outputItem.text, - }; - case "input_image": { - if (!outputItem.image_url) return; - return { - type: "image-url" as const, - url: outputItem.image_url, - }; - } - case "input_file": { - if (outputItem.file_data) - return { - type: "file-data" as const, - data: outputItem.file_data, - mediaType: yield* detectMimeTypeFromBase64EncodedString( - outputItem.file_data, - ), - filename: outputItem.filename ?? crypto.randomUUID(), - }; - else if (outputItem.file_url) - return { - type: "file-url" as const, - url: outputItem.file_url, - }; - else { - return; - } - } - case "input_video": { - return { - type: "file-url" as const, - url: outputItem.video_url, - }; - } - } - }), - ), - )).filter(isNotNullable), - } satisfies ToolResultPart["output"]; - }))(), - }, - ], - }, - ] satisfies ModelMessage[]; - } - default: { - return yield* Effect.fail( - new AIServiceError({ - message: `Unsupported input item type: ${(createResponseBodyInputItem as { type: string }).type}`, - }), - ); - } - } - }); - -const EFFORT_TO_BUDGET_TOKENS: Record = { - low: 1024, - medium: 4096, - high: 10000, - xhigh: 32000, -}; - -const EFFORT_TO_NOVA_REASONING_EFFORT: Record = { - low: "low", - medium: "medium", - high: "high", - xhigh: "max", -}; - -export const convertCreateResponseBodyReasoningToProviderOptions = ( - reasoning: CreateResponseBody["reasoning"], - bedrockModelId?: string, - hasStructuredOutput?: boolean, -) => { - if (!reasoning) return undefined; - - const effort = reasoning.effort; - const summary = reasoning.summary; - - if (!effort && !summary) return undefined; - - const openaiOptions = { - ...(effort && effort !== "none" - ? { reasoningEffort: effort === "xhigh" ? "high" : effort } - : {}), - ...(summary ? { reasoningSummary: summary } : {}), - }; - - const anthropicThinkingConfig = - !effort || effort === "none" - ? ({ type: "disabled" } as const) - : ({ type: "enabled", budgetTokens: EFFORT_TO_BUDGET_TOKENS[effort] ?? 4096 } as const); - - const bedrockReasoningConfig = (() => { - if (!effort || effort === "none") return undefined; - if (hasStructuredOutput) return undefined; - - const isAnthropicModel = bedrockModelId?.includes("anthropic") ?? false; - const isAmazonModel = - bedrockModelId?.includes("amazon") ?? false; - - if (isAnthropicModel) { - return { - type: "enabled" as const, - budgetTokens: EFFORT_TO_BUDGET_TOKENS[effort] ?? 4096, - }; - } - if (isAmazonModel) { - return { - type: "enabled" as const, - maxReasoningEffort: EFFORT_TO_NOVA_REASONING_EFFORT[effort] ?? "medium", - }; - } - return undefined; - })(); - - return { - openai: openaiOptions, - anthropic: { thinking: anthropicThinkingConfig }, - ...(bedrockReasoningConfig - ? { bedrock: { reasoningConfig: bedrockReasoningConfig } } - : {}), - }; -}; - -export const convertCreateResponseBodyToolsToCallSettingsTools = ( - tools: CreateResponseBody["tools"], - toolChoice: CreateResponseBody["tool_choice"], -): ToolSet | undefined => { - if (!tools?.length) return undefined; - - const filteredTools = - toolChoice && - typeof toolChoice !== "string" && - toolChoice.type === "allowed_tools" - ? tools.filter((t) => - toolChoice.tools.some((allowed) => allowed.name === t.name), - ) - : tools; - - if (!filteredTools.length) return undefined; - - return Object.fromEntries( - filteredTools.map((t) => [ - t.name, - { - ...(t.description != null ? { description: t.description } : {}), - inputSchema: jsonSchema( - (t.parameters as Parameters[0]) ?? { - type: "object" as const, - }, - ), - ...(t.strict != null ? { strict: t.strict } : {}), - }, - ]), - ) as ToolSet; -}; - -export const convertCreateResponseBodyToolChoiceToCallSettingsToolChoice = ( - toolChoice: CreateResponseBody["tool_choice"], -) => { - if (!toolChoice) return undefined; - if (typeof toolChoice === "string") return toolChoice; - if (toolChoice.type === "function") - return { type: "tool" as const, toolName: toolChoice.name }; - return toolChoice.mode ?? ("auto" as const); -}; - -export const convertCreateResponseBodyTextFormatToCallSettingsOutput = ( - text: CreateResponseBody["text"], -) => { - const format = text?.format; - if (!format || format.type === "text") return undefined; - return Output.object({ - schema: jsonSchema( - (format.schema as Parameters[0]) ?? { - type: "object" as const, - }, - ), - ...(format.name != null ? { name: format.name } : {}), - ...(format.description != null ? { description: format.description } : {}), - }); -}; diff --git a/packages/api_platform/src/services/ai/stream-events.ts b/packages/api_platform/src/services/ai/stream-events.ts index c6d06be..bbd2735 100644 --- a/packages/api_platform/src/services/ai/stream-events.ts +++ b/packages/api_platform/src/services/ai/stream-events.ts @@ -24,6 +24,38 @@ type ToolInputPart = { const getTextDelta = (part: ToolInputPart): string => part.delta ?? part.text ?? part.argsTextDelta ?? ""; +const buildErrorEvent = ( + error: unknown, + sequenceNumber: number, +): StreamingEvent => { + const errorObject = + typeof error === "object" && error !== null + ? (error as Record) + : undefined; + return { + type: "error", + sequence_number: sequenceNumber, + error: { + type: (typeof errorObject?.type === "string" + ? errorObject?.type + : typeof errorObject?.name === "string" + ? errorObject?.name + : "error") as string, + code: typeof errorObject?.code === "string" ? errorObject?.code : null, + message: + typeof errorObject?.message === "string" + ? errorObject?.message + : typeof error === "string" + ? error + : "Unknown error", + param: typeof errorObject?.param === "string" ? errorObject?.param : null, + ...(typeof errorObject?.headers === "object" && errorObject?.headers !== null + ? { headers: errorObject?.headers as Record } + : {}), + }, + }; +}; + export const streamToEvents = ( fullStream: AsyncIterable>, startingSequenceNumber = 0, @@ -375,33 +407,7 @@ export const streamToEvents = ( } case "error": { const errorValue = (part as { error?: unknown }).error; - const errorObject = - typeof errorValue === "object" && errorValue !== null - ? (errorValue as Record) - : undefined; - const errorEvent: StreamingEvent = { - type: "error", - sequence_number: nextSequenceNumber(), - error: { - type: (typeof errorObject?.type === "string" - ? errorObject?.type - : typeof errorObject?.name === "string" - ? errorObject?.name - : "error") as string, - code: typeof errorObject?.code === "string" ? errorObject?.code : null, - message: - typeof errorObject?.message === "string" - ? errorObject?.message - : typeof errorValue === "string" - ? errorValue - : "Unknown error", - param: typeof errorObject?.param === "string" ? errorObject?.param : null, - ...(typeof errorObject?.headers === "object" && errorObject?.headers !== null - ? { headers: errorObject?.headers as Record } - : {}), - }, - }; - yield [errorEvent]; + yield [buildErrorEvent(errorValue, nextSequenceNumber())]; break; } case "finish": { @@ -412,31 +418,7 @@ export const streamToEvents = ( } } } catch (e) { - const errorObject = - typeof e === "object" && e !== null ? (e as Record) : undefined; - const errorEvent: StreamingEvent = { - type: "error", - sequence_number: nextSequenceNumber(), - error: { - type: (typeof errorObject?.type === "string" - ? errorObject?.type - : typeof errorObject?.name === "string" - ? errorObject?.name - : "error") as string, - code: typeof errorObject?.code === "string" ? errorObject?.code : null, - message: - typeof errorObject?.message === "string" - ? errorObject?.message - : typeof e === "string" - ? e - : "Unknown error", - param: typeof errorObject?.param === "string" ? errorObject?.param : null, - ...(typeof errorObject?.headers === "object" && errorObject?.headers !== null - ? { headers: errorObject?.headers as Record } - : {}), - }, - }; - yield [errorEvent]; + yield [buildErrorEvent(e, nextSequenceNumber())]; } } })(); diff --git a/packages/api_platform/src/services/credentials.ts b/packages/api_platform/src/services/credentials.ts index aa32592..379a064 100644 --- a/packages/api_platform/src/services/credentials.ts +++ b/packages/api_platform/src/services/credentials.ts @@ -1,5 +1,5 @@ import { Effect, Data } from "effect"; -import { VaultService } from "vault"; +import { VaultService } from "@enfinyte/services"; import { type ProviderCredentials, Providers } from "common"; import { getProviderEntry } from "./provider-registry"; diff --git a/packages/api_platform/src/services/pmr.ts b/packages/api_platform/src/services/pmr.ts index cb995af..14b560e 100644 --- a/packages/api_platform/src/services/pmr.ts +++ b/packages/api_platform/src/services/pmr.ts @@ -1,7 +1,7 @@ import type { CreateResponseBody } from "common"; import { Effect, Data } from "effect"; -import { ResolverService } from "resolver"; +import { ResolverService } from "@enfinyte/services"; export class PMRError extends Data.TaggedError("PMRError")<{ cause?: unknown; diff --git a/packages/api_platform/src/services/responses/index.ts b/packages/api_platform/src/services/responses/index.ts index 50aa0db..2c19564 100644 --- a/packages/api_platform/src/services/responses/index.ts +++ b/packages/api_platform/src/services/responses/index.ts @@ -1,8 +1,7 @@ import type { CreateResponseBody, ResponseResource, StreamingEvent } from "common"; import { Effect, Data, Stream } from "effect"; -import { LedgerService } from "ledger"; -import { ResolverService } from "resolver"; +import { LedgerService, ResolverService } from "@enfinyte/services"; import type { RequestParams } from "../request-context"; diff --git a/packages/backend/package.json b/packages/backend/package.json index 13f3205..ed924ac 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -13,10 +13,8 @@ "effect": "^3.19.15", "hono": "^4.11.7", "kysely": "^0.28.11", - "ledger": "workspace:*", - "pg": "^8.18.0", - "resolver": "workspace:*", - "vault": "workspace:*" + "@enfinyte/services": "workspace:*", + "pg": "^8.18.0" }, "devDependencies": { "@types/bun": "latest" diff --git a/packages/backend/src/layers.ts b/packages/backend/src/layers.ts index 712331c..ee7f4ae 100644 --- a/packages/backend/src/layers.ts +++ b/packages/backend/src/layers.ts @@ -1,5 +1,5 @@ import { Layer } from "effect"; -import { fromEnv as LedgerServiceLive } from "ledger"; +import { LedgerServiceLive, ResolverServiceLive } from "@enfinyte/services"; import { AppConfig, AppConfigLive } from "./config"; import { DatabasePoolLive } from "./database/pool"; import { DatabaseServiceLive } from "./database/client"; @@ -7,7 +7,6 @@ import { SecretRepositoryLive } from "./database/repositories/secret"; import { AuthServiceLive } from "./services/auth"; import { ApiKeyServiceLive } from "./services/apikey"; import { SecretServiceLive } from "./services/secret"; -import { ResolverServiceLive } from "resolver"; const AppConfigExposed = Layer.effect(AppConfig, AppConfig).pipe(Layer.provide(AppConfigLive)); diff --git a/packages/backend/src/routes/analytics.ts b/packages/backend/src/routes/analytics.ts index bbbb831..b613f46 100644 --- a/packages/backend/src/routes/analytics.ts +++ b/packages/backend/src/routes/analytics.ts @@ -1,6 +1,6 @@ import { Effect, Schema } from "effect"; import { Hono } from "hono"; -import { LedgerService } from "ledger"; +import { LedgerService } from "@enfinyte/services"; import { getAuthenticatedUser } from "../middleware/auth"; import { RequestValidationError } from "../errors"; import { IntervalSchema } from "../schemas"; diff --git a/packages/backend/src/routes/models.ts b/packages/backend/src/routes/models.ts index 3428300..10f76ad 100644 --- a/packages/backend/src/routes/models.ts +++ b/packages/backend/src/routes/models.ts @@ -1,6 +1,6 @@ import { Effect } from "effect"; import { Hono } from "hono"; -import { ResolverService } from "resolver"; +import { ResolverService } from "@enfinyte/services"; import { getAuthenticatedUser } from "../middleware/auth"; import { runHandler } from "../runtime"; diff --git a/packages/backend/src/services/apikey.ts b/packages/backend/src/services/apikey.ts index bd192f7..d952d51 100644 --- a/packages/backend/src/services/apikey.ts +++ b/packages/backend/src/services/apikey.ts @@ -1,7 +1,7 @@ import type { VerifyApiKeyResult } from "common"; import { Context, Effect, Layer } from "effect"; -import { ProviderModelParseError } from "resolver"; +import type { ProviderModelParseError } from "@enfinyte/services"; import { parseProviderModelImpl } from "resolver/src/parser"; import type { AuthInstance } from "./auth"; diff --git a/packages/backend/src/services/secret.ts b/packages/backend/src/services/secret.ts index a359090..2ef6f63 100644 --- a/packages/backend/src/services/secret.ts +++ b/packages/backend/src/services/secret.ts @@ -1,5 +1,5 @@ import { Context, Effect, Layer } from "effect"; -import { VaultService, VaultServiceLive } from "vault"; +import { VaultService, VaultServiceLive } from "@enfinyte/services"; import { DatabaseServiceError } from "../database/client"; import { SecretRepository, SecretRepositoryLive } from "../database/repositories/secret"; diff --git a/packages/resolver/src/data_manager/accessor.ts b/packages/resolver/src/data_manager/accessor.ts index 5e2b565..408960f 100644 --- a/packages/resolver/src/data_manager/accessor.ts +++ b/packages/resolver/src/data_manager/accessor.ts @@ -1,29 +1,16 @@ import { Effect } from "effect"; import { type IntentPair } from "../types"; +import { dataManagerLog } from "../log"; import * as Redis from "../redis/index"; export const getPotentialModelsForIntentPair = (pair: IntentPair) => Effect.gen(function* () { - yield* Effect.logDebug("Reading Categories data").pipe( - Effect.annotateLogs({ - service: "DataManager", - operation: "getOpenRouterDataByPair", - intent: pair.intent, - intentPolicy: pair.intentPolicy, - }), - ); + const l = dataManagerLog("getPotentialModels"); - const result = yield* Redis.getModelsForCategoryAndOrder(pair.intent, pair.intentPolicy); + yield* l.debug("Reading category data", { + intent: pair.intent, + intentPolicy: pair.intentPolicy, + }); - yield* Effect.logDebug("OpenRouter data loaded").pipe( - Effect.annotateLogs({ - service: "DataManager", - operation: "getOpenRouterDataByPair", - intent: pair.intent, - intentPolicy: pair.intentPolicy, - slugCount: result.length, - }), - ); - - return result; + return yield* Redis.getModelsForCategoryAndOrder(pair.intent, pair.intentPolicy); }); diff --git a/packages/resolver/src/data_manager/fetch.ts b/packages/resolver/src/data_manager/fetch.ts index ca49068..292eec6 100644 --- a/packages/resolver/src/data_manager/fetch.ts +++ b/packages/resolver/src/data_manager/fetch.ts @@ -1,6 +1,7 @@ import { SUPPORTED_PROVIDERS } from "common"; import { Effect, Duration, Schema } from "effect"; +import { dataManagerLog } from "../log"; import * as Redis from "../redis/index"; import { DataFetchError } from "../types"; import { ORDERS, CATEGORIES } from "../types"; @@ -29,9 +30,8 @@ const openrouterCategoryUrl = (category: string, order: string) => { const fetchJson = (url: string) => Effect.gen(function* () { - yield* Effect.logDebug("Fetching JSON").pipe( - Effect.annotateLogs({ service: "DataManager", operation: "fetchJson", url }), - ); + const l = dataManagerLog("fetchJson"); + yield* l.debug("Fetching", { url }); const response = yield* Effect.tryPromise({ try: () => fetch(url), @@ -41,19 +41,7 @@ const fetchJson = (url: string) => message: `API call to ${url} failed`, cause: error, }), - }).pipe( - Effect.tapError((err) => - Effect.logError("API call failed").pipe( - Effect.annotateLogs({ - service: "DataManager", - operation: "fetchJson", - url, - reason: err.reason, - cause: err.cause instanceof Error ? err.cause.message : String(err.cause), - }), - ), - ), - ); + }); return yield* Effect.tryPromise({ try: () => response.json(), @@ -63,42 +51,20 @@ const fetchJson = (url: string) => message: `JSON parsing failed for response from ${url}`, cause: error, }), - }).pipe( - Effect.tapError((err) => - Effect.logError("JSON parse failed").pipe( - Effect.annotateLogs({ - service: "DataManager", - operation: "fetchJson", - url, - reason: err.reason, - }), - ), - ), - ); + }); }); -function fetchAndAction( - url: string, - action: (json: A) => Effect.Effect, -): Effect.Effect { - return Effect.gen(function* () { - const json = (yield* fetchJson(url)) as A; - return yield* action(json); - }); -} - -const openRouterAction = (category: string, order: string) => (json: string[]) => +const fetchOpenRouterCategory = (category: string, order: string) => Effect.gen(function* () { + const json: unknown = yield* fetchJson(openrouterCategoryUrl(category, order)); + const parsed = yield* Schema.decodeUnknown(OpenRouterMapSchema)(json).pipe( - Effect.tapError((err) => - Effect.logError("OpenRouter schema decode failed").pipe( - Effect.annotateLogs({ - service: "DataManager", - operation: "populate", - key: `${category}:${order}`, - cause: String(err), + Effect.mapError( + () => + new DataFetchError({ + reason: "DataParseFailed", + message: `OpenRouter schema decode failed for ${category}:${order}`, }), - ), ), ); @@ -106,18 +72,15 @@ const openRouterAction = (category: string, order: string) => (json: string[]) = return parsed; }); -const modelsDevAction = () => (json: Record) => +const processModelsDevModels = (json: unknown) => Effect.gen(function* () { const parsed = yield* Schema.decodeUnknown(ProviderModelMapSchema)(json).pipe( - Effect.tapError((err) => - Effect.logError("models.dev schema decode failed").pipe( - Effect.annotateLogs({ - service: "DataManager", - operation: "populate", - key: `models.dev`, - cause: String(err), + Effect.mapError( + () => + new DataFetchError({ + reason: "DataParseFailed", + message: "models.dev model map schema decode failed", }), - ), ), ); @@ -130,17 +93,18 @@ const modelsDevAction = () => (json: Record) => ); yield* Redis.bulkSetModelsForProvider(supported); + return supported; + }); +const processModelsDevCosts = (json: unknown) => + Effect.gen(function* () { const parsedCost = yield* Schema.decodeUnknown(ProviderModelToCostSchema)(json).pipe( - Effect.tapError((err) => - Effect.logError("models.dev cost decode failed").pipe( - Effect.annotateLogs({ - service: "DataManager", - operation: "populate", - key: `models.dev`, - cause: String(err), + Effect.mapError( + () => + new DataFetchError({ + reason: "DataParseFailed", + message: "models.dev cost schema decode failed", }), - ), ), ); @@ -155,47 +119,32 @@ const modelsDevAction = () => (json: Record) => } yield* Redis.bulkSetProviderModelCost(supportedCost); + }); +const fetchModelsDev = () => + Effect.gen(function* () { + const json: unknown = yield* fetchJson(MODELS_DEV_BASE); + const supported = yield* processModelsDevModels(json); + yield* processModelsDevCosts(json); return supported; }); const populate = () => Effect.gen(function* () { - yield* Effect.logInfo("Populating data cache").pipe( - Effect.annotateLogs({ service: "DataManager", operation: "populate" }), - ); + const l = dataManagerLog("populate"); - const categoryFetches = CATEGORIES.flatMap((category) => - ORDERS.map((order) => - fetchAndAction(openrouterCategoryUrl(category, order), openRouterAction(category, order)), - ), - ); + yield* l.info("Starting data cache population", { + categoryCount: CATEGORIES.length, + orderCount: ORDERS.length, + }); - const totalFetches = 1 + categoryFetches.length; - yield* Effect.logInfo("Starting concurrent data fetches").pipe( - Effect.annotateLogs({ - service: "DataManager", - operation: "populate", - totalFetches, - categoryCount: CATEGORIES.length, - orderCount: ORDERS.length, - }), + const categoryFetches = CATEGORIES.flatMap((category) => + ORDERS.map((order) => fetchOpenRouterCategory(category, order)), ); const [modelsDev, ...openRouter] = yield* Effect.all( - [fetchAndAction(MODELS_DEV_BASE, modelsDevAction()), ...categoryFetches], - { - concurrency: "unbounded", - }, - ); - - yield* Effect.logInfo("All data fetches completed").pipe( - Effect.annotateLogs({ - service: "DataManager", - operation: "populate", - totalFetches, - openRouterSlugCount: (openRouter.flat() as string[]).length, - }), + [fetchModelsDev(), ...categoryFetches], + { concurrency: "unbounded" }, ); const modelMap = generateModelMap( @@ -203,42 +152,25 @@ const populate = () => modelsDev as Readonly>, ); - const mapEntryCount = Object.keys(modelMap).length; - yield* Effect.logInfo("Model map generated").pipe( - Effect.annotateLogs({ - service: "DataManager", - operation: "populate", - mapEntryCount, - }), - ); - yield* Redis.bulkSetProvidersForModels(modelMap); yield* Redis.markLastFetchPoint(); + + yield* l.info("Data cache populated", { mapEntryCount: Object.keys(modelMap).length }); }); export const runDataFetch = () => Effect.gen(function* () { + const l = dataManagerLog("runDataFetch"); const lastFetchedAt = yield* Redis.getLastFetchPoint(); if (lastFetchedAt) { - yield* Effect.logDebug("Data cache is fresh, skipping fetch").pipe( - Effect.annotateLogs({ - service: "DataManager", - operation: "runDataFetch", - lastFetchedAt: new Date(lastFetchedAt).toISOString(), - ttlHours: Duration.toHours(DATA_TTL), - }), - ); + yield* l.debug("Data cache is fresh, skipping fetch", { + lastFetchedAt: new Date(lastFetchedAt).toISOString(), + ttlHours: Duration.toHours(DATA_TTL), + }); return; - } else { - yield* Effect.logInfo( - "Either Data cache is stale or No data cache found, performing fetch", - ).pipe(Effect.annotateLogs({ service: "DataManager", operation: "runDataFetch" })); } + yield* l.info("Data cache stale or missing, performing fetch"); yield* populate(); - - yield* Effect.logInfo("Data cache refreshed").pipe( - Effect.annotateLogs({ service: "DataManager", operation: "runDataFetch" }), - ); }); diff --git a/packages/resolver/src/data_manager/model_map.ts b/packages/resolver/src/data_manager/model_map.ts index 8041650..eb1810a 100644 --- a/packages/resolver/src/data_manager/model_map.ts +++ b/packages/resolver/src/data_manager/model_map.ts @@ -1,6 +1,6 @@ import type { ResolvedResponse } from "common"; -import { parseModelId } from "../parser/parse_model_id.ts"; -import { modelsMatch } from "../parser/match_models.ts"; +import { parseModelId } from "../parser/parse_model_id"; +import { modelsMatch } from "../parser/match_models"; export function generateModelMap( openRouterSlugs: readonly string[], diff --git a/packages/resolver/src/index.ts b/packages/resolver/src/index.ts index 6187eeb..1e9e1df 100644 --- a/packages/resolver/src/index.ts +++ b/packages/resolver/src/index.ts @@ -47,10 +47,7 @@ export class ResolverService extends Context.Tag("ResolverService")< >; getCostForModel: ( canonicalProviderModelName: string, - ) => Effect.Effect< - { input: number; output: number } | null, - ParseError | Redis.RedisError - >; + ) => Effect.Effect<{ input: number; output: number } | null, ParseError | Redis.RedisError>; } >() {} @@ -58,45 +55,29 @@ export const ResolverServiceLive = Layer.effect( ResolverService, Effect.gen(function* () { const redis = yield* Redis.Redis; - return ResolverService.of({ - resolve(options, userdId, userProviders, analysisTarget) { - return Effect.gen(function* () { - yield* Effect.logInfo("Resolve request received").pipe( - Effect.annotateLogs({ - service: "Resolver", - operation: "resolve", - model: typeof options.model === "string" ? options.model : typeof options.model, - providerCount: userProviders.length, - analysisTarget: analysisTarget ?? "per_prompt", - }), - ); - yield* runDataFetch(); - const pairs = yield* resolveImpl(options, userdId, userProviders, analysisTarget); + const withRedis = (effect: Effect.Effect) => + effect.pipe(Effect.provideService(Redis.Redis, redis)); - yield* Effect.logInfo("Resolve completed").pipe( - Effect.annotateLogs({ - service: "Resolver", - operation: "resolve", - pairs_length: pairs.length, - }), - ); - - return pairs; - }).pipe(Effect.provideService(Redis.Redis, redis)); + return ResolverService.of({ + resolve(options, userdId, userProviders, analysisTarget) { + return withRedis( + Effect.gen(function* () { + yield* runDataFetch(); + return yield* resolveImpl(options, userdId, userProviders, analysisTarget); + }), + ); }, getAvailableModels() { - return Effect.gen(function* () { - yield* runDataFetch(); - return yield* Redis.getAllModelsGroupedByProvider(); - }).pipe(Effect.provideService(Redis.Redis, redis)); + return withRedis( + Effect.gen(function* () { + yield* runDataFetch(); + return yield* Redis.getAllModelsGroupedByProvider(); + }), + ); }, getCostForModel(canonicalProviderModelName) { - return Effect.gen(function* () { - const cost = yield* Redis.getCostForModel(canonicalProviderModelName); - if (Array.isArray(cost) && cost.length === 0) return null; - return cost as { input: number; output: number }; - }).pipe(Effect.provideService(Redis.Redis, redis)); + return withRedis(Redis.getCostForModel(canonicalProviderModelName)); }, }); }), diff --git a/packages/resolver/src/log.ts b/packages/resolver/src/log.ts new file mode 100644 index 0000000..a6403dc --- /dev/null +++ b/packages/resolver/src/log.ts @@ -0,0 +1,15 @@ +import { Effect } from "effect"; + +const log = (service: string) => (operation: string) => ({ + info: (message: string, extra?: Record) => + Effect.logInfo(message).pipe(Effect.annotateLogs({ service, operation, ...extra })), + debug: (message: string, extra?: Record) => + Effect.logDebug(message).pipe(Effect.annotateLogs({ service, operation, ...extra })), + error: (message: string, extra?: Record) => + Effect.logError(message).pipe(Effect.annotateLogs({ service, operation, ...extra })), + warn: (message: string, extra?: Record) => + Effect.logWarning(message).pipe(Effect.annotateLogs({ service, operation, ...extra })), +}); + +export const resolverLog = log("Resolver"); +export const dataManagerLog = log("DataManager"); diff --git a/packages/resolver/src/parser/parse_model_id.ts b/packages/resolver/src/parser/parse_model_id.ts deleted file mode 100644 index b4f41af..0000000 --- a/packages/resolver/src/parser/parse_model_id.ts +++ /dev/null @@ -1,967 +0,0 @@ -export interface ParsedModelId { - readonly raw: string; - - /** The platform/provider the model ID originates from. */ - readonly platform: string; - - /** Bedrock region prefix: "us" | "eu" | "global". */ - readonly region?: string | undefined; - - /** The upstream vendor extracted from provider-prefixed IDs. */ - readonly vendor?: string | undefined; - - /** Bedrock deployment version suffix, e.g. "v1:0". */ - readonly deploymentVersion?: string | undefined; - - /** Vertex-Anthropic routing tag, e.g. "20250514" or "default". */ - readonly routingTag?: string | undefined; - - /** Whether the "-maas" suffix was present (Google Vertex). */ - readonly maasSuffix?: boolean | undefined; - - // -- Core matching features (the canonical identity) ---------------------- - - /** Model family root: "claude", "gpt", "gemini", "llama", "grok", "glm", etc. */ - readonly family: string; - - /** - * Normalized generation string using dots: "4.5", "3.7", "2.0", "4o". - * Empty string when no generation is detected. - */ - readonly generation: string; - - /** Size/quality tier: "opus", "sonnet", "haiku", "mini", "nano", "flash", "pro", etc. */ - readonly tier?: string | undefined; - - /** - * Capability/mode variant: "codex", "chat", "coder", "fast", "instruct", - * "thinking", "reasoning", "vision", "vl", etc. - */ - readonly variant?: string | undefined; - - // -- Secondary features --------------------------------------------------- - - /** Total parameter count in billions. */ - readonly sizeBillions?: number | undefined; - - /** MoE active parameter count in billions. */ - readonly activeBillions?: number | undefined; - - /** Release/snapshot date normalized to "YYYYMMDD". */ - readonly date?: string | undefined; - - /** Context window in thousands (e.g. 128 for 128k). */ - readonly contextK?: number | undefined; - - /** Quantization format, e.g. "fp8", "fp16". */ - readonly quantization?: string | undefined; - - // -- Flags ---------------------------------------------------------------- - - /** Model marketed as open-source/open-weight (e.g. gpt-oss). */ - readonly isOpenSource?: boolean | undefined; - - /** Safety/guard/safeguard model. */ - readonly isSafety?: boolean | undefined; - - /** Explicitly a preview release. */ - readonly isPreview?: boolean | undefined; -} - -const REGIONS = new Set(["us", "eu", "ap", "global"]); - -const CLAUDE_TIERS = new Set(["opus", "sonnet", "haiku", "instant"]); - -const SIZE_TIERS = new Set([ - // Quality tiers (Claude) - "opus", - "sonnet", - "haiku", - "instant", - // Size tiers (GPT, Phi, Mistral, etc.) - "nano", - "micro", - "mini", - "small", - "medium", - "large", - // Speed/cost tiers (Gemini, Amazon Nova) - "lite", - "flash", - "express", - "turbo", - // Premium tiers - "pro", - "premier", - "max", -]); - -const VARIANTS = new Set([ - "instruct", - "chat", - "codex", - "coder", - "code", - "vision", - "vl", - "multimodal", - "thinking", - "reasoning", - "fast", - "it", // Google's "instruction-tuned" abbreviation - "moe", - "deep-research", -]); - -const FAMILY_ROOTS = [ - "gpt-oss-safeguard", - "gpt-oss", - "gpt", - "claude", - "gemini", - "gemma", - "meta-llama", - "llama", - "grok-code", - "grok", - "glm", - "phi", - "mistral", - "mixtral", - "ministral", - "voxtral", - "codestral", - "deepseek", - "command-r-plus", - "command-r", - "command", - "jamba", - "titan", - "nova", - "nemotron", - "palmyra", - "minimax", - "kimi", - "qwen", - "lfm", - "step", - "trinity", - "pony", - "o", // OpenAI o-series (o1, o3, o4-mini) — must be last, very short -]; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function dotVersionToDash(s: string): string { - return s.replace(/(\d+)\.(\d+)/g, "$1-$2"); -} - -function extractDate8(s: string): { date: string; rest: string } | null { - const m = s.match(/(^|[-_])(\d{8})($|[-_])/); - if (!m || m[2] === undefined) return null; - const year = parseInt(m[2].slice(0, 4)); - // Sanity check: year between 2020 and 2030 - if (year < 2020 || year > 2030) return null; - const start = (m.index ?? 0) + (m[1]?.length ?? 0); - const end = start + 8; - const rest = s.slice(0, start > 0 ? start - 1 : start) + s.slice(end); - return { date: m[2], rest: rest.replace(/^-|-$/g, "") }; -} - -function extractDateISO(s: string): { date: string; rest: string } | null { - const m = s.match(/(^|[-_])(\d{4})-(\d{2})-(\d{2})($|[-_])/); - if (!m || m[2] === undefined || m[3] === undefined || m[4] === undefined) return null; - const year = parseInt(m[2]); - if (year < 2020 || year > 2030) return null; - const date = `${m[2]}${m[3]}${m[4]}`; - const full = `${m[2]}-${m[3]}-${m[4]}`; - const start = (m.index ?? 0) + (m[1]?.length ?? 0); - const end = start + full.length; - const rest = s.slice(0, start > 0 ? start - 1 : start) + s.slice(end); - return { date, rest: rest.replace(/^-|-$/g, "") }; -} - -interface StrippedResult { - bareId: string; - region?: string | undefined; - vendor?: string | undefined; - deploymentVersion?: string | undefined; - routingTag?: string | undefined; - maasSuffix?: boolean | undefined; -} - -function stripBedrock(raw: string): StrippedResult { - let id = raw; - let region: string | undefined; - let vendor: string | undefined; - let deploymentVersion: string | undefined; - - // Strip deployment version (:N or :N:Nk) - const dvMatch = id.match(/:(\d[\w:]*?)$/); - if (dvMatch && dvMatch[1] !== undefined) { - deploymentVersion = dvMatch[1]; - id = id.slice(0, id.length - dvMatch[0].length); - } - - // Split on "." to get [region?, vendor, ...model parts] - const dotParts = id.split("."); - if (dotParts.length >= 2) { - let startIdx = 0; - - // Check for region prefix - if (dotParts[0] !== undefined && REGIONS.has(dotParts[0])) { - region = dotParts[0]; - startIdx = 1; - } - - // Vendor is next segment - vendor = dotParts[startIdx]; - - // Remaining segments are the model name (re-join with ".") - id = dotParts.slice(startIdx + 1).join("."); - } - - // Strip the Bedrock API version suffix: -v1, -v2 (without colon — some have it stripped already) - const apiVerMatch = id.match(/-v(\d+)$/); - if (apiVerMatch) { - if (deploymentVersion === undefined) { - deploymentVersion = `v${apiVerMatch[1]}`; - } - id = id.slice(0, id.length - apiVerMatch[0].length); - } - - return { bareId: id, region, vendor, deploymentVersion }; -} - -function stripVertexAnthropic(raw: string): StrippedResult { - const atIdx = raw.indexOf("@"); - if (atIdx === -1) return { bareId: raw }; - - const bareId = raw.slice(0, atIdx); - const routingTag = raw.slice(atIdx + 1); - return { bareId, routingTag }; -} - -function stripVertex(raw: string): StrippedResult { - let id = raw; - let vendor: string | undefined; - let maasSuffix: boolean | undefined; - - // Third-party models: "org/model-maas" - const slashIdx = id.indexOf("/"); - if (slashIdx !== -1) { - vendor = id.slice(0, slashIdx); - id = id.slice(slashIdx + 1); - } - - // Strip -maas suffix - if (id.endsWith("-maas")) { - maasSuffix = true; - id = id.slice(0, -5); - } - - return { bareId: id, vendor, maasSuffix }; -} - -function stripOpenRouterSlug(raw: string): StrippedResult { - const slashIdx = raw.indexOf("/"); - if (slashIdx === -1) return { bareId: raw }; - - const vendor = raw.slice(0, slashIdx); - const bareId = raw.slice(slashIdx + 1); - return { bareId, vendor }; -} - -function stripMinimal(raw: string): StrippedResult { - return { bareId: raw }; -} - -function stripProviderWrapper(raw: string, platform: string): StrippedResult { - switch (platform) { - case "amazon-bedrock": - return stripBedrock(raw); - case "google-vertex-anthropic": - return stripVertexAnthropic(raw); - case "google-vertex": - return stripVertex(raw); - case "openrouter": - return stripOpenRouterSlug(raw); - case "anthropic": - case "azure": - case "openai": - default: - return stripMinimal(raw); - } -} - -// --------------------------------------------------------------------------- -// Phase 2: Parse bare model name into canonical features -// --------------------------------------------------------------------------- - -interface ParsedBareId { - family: string; - generation: string; - tier?: string | undefined; - variant?: string | undefined; - sizeBillions?: number | undefined; - activeBillions?: number | undefined; - date?: string | undefined; - contextK?: number | undefined; - quantization?: string | undefined; - isOpenSource?: boolean | undefined; - isSafety?: boolean | undefined; - isPreview?: boolean | undefined; -} - -/** - * Detect the family root from a bare model ID. - * Returns the family name and the remaining string after the family prefix. - */ -function detectFamily(bareId: string): { family: string; rest: string } | null { - const lower = bareId.toLowerCase(); - - for (const root of FAMILY_ROOTS) { - // For single-character roots like "o", require it to be followed by a digit - if (root === "o") { - const m = lower.match(/^o(\d)/); - if (m) { - return { family: "o", rest: bareId.slice(1) }; - } - continue; - } - - // Check if the bareId starts with this root followed by a delimiter or digit or end - const normalized = dotVersionToDash(lower); - const rootDashed = dotVersionToDash(root); - if ( - normalized.startsWith(rootDashed) && - (normalized.length === rootDashed.length || - normalized[rootDashed.length] === "-" || - normalized[rootDashed.length] === "_" || - /\d/.test(normalized[rootDashed.length] ?? "")) - ) { - let restStart = root.length; - // If the actual bareId uses dashes where root uses dashes, consume correctly - // We need to match in the original string (may have dots) - const origPrefix = bareId.slice(0, rootDashed.length); - if (dotVersionToDash(origPrefix.toLowerCase()) === rootDashed) { - restStart = rootDashed.length; - } - - let rest = bareId.slice(restStart); - // Strip leading delimiter - if (rest.startsWith("-") || rest.startsWith("_")) { - rest = rest.slice(1); - } - return { family: root, rest }; - } - } - - // Fallback: first token is the family - const firstDelim = bareId.search(/[-_]/); - if (firstDelim === -1) return { family: bareId.toLowerCase(), rest: "" }; - return { - family: bareId.slice(0, firstDelim).toLowerCase(), - rest: bareId.slice(firstDelim + 1), - }; -} - -/** - * Parse Claude-specific model names, handling the gen3/gen4+ naming inversion. - * - * Gen 3 scheme: claude-{GEN}-{TIER}-{DATE}-{API_VER} - * e.g. "3-5-sonnet-20240620" - * - * Gen 4+ scheme: claude-{TIER}-{GEN}-{DATE}-{API_VER} - * e.g. "sonnet-4-5-20250929" - */ -function parseClaude(rest: string): ParsedBareId { - const result: ParsedBareId = { - family: "claude", - generation: "", - }; - - // Remove the rest's date first so it doesn't interfere with generation parsing - let working = rest; - const dateResult = extractDate8(working); - if (dateResult) { - result.date = dateResult.date; - working = dateResult.rest; - } - - // Split into tokens - const tokens = working.split(/[-_]/).filter((t) => t.length > 0); - - // Determine naming scheme: if first token is a known tier, it's gen4+ scheme - const firstToken = tokens[0]?.toLowerCase() ?? ""; - - if (CLAUDE_TIERS.has(firstToken)) { - // Gen 4+ scheme: {TIER}-{GEN_MAJOR}[-{GEN_MINOR}]-... - result.tier = firstToken; - - // Collect generation digits (may include dot-versions like "4.5") - const genParts: string[] = []; - let i = 1; - while (i < tokens.length && /^\d[\d.]*$/.test(tokens[i] ?? "")) { - genParts.push(tokens[i]!); - i++; - } - - if (genParts.length > 0) { - result.generation = genParts.join("."); - } - - // Check for "latest" or "default" in remaining tokens - for (; i < tokens.length; i++) { - const tok = tokens[i]?.toLowerCase() ?? ""; - if (tok === "latest" || tok === "default") { - // skip — these are aliases - } - } - } else if (/^\d+$/.test(firstToken)) { - // Gen 3 scheme: {GEN_MAJOR}[-{GEN_MINOR}]-{TIER}-... - const genParts: string[] = [firstToken]; - let i = 1; - while (i < tokens.length && /^\d+$/.test(tokens[i] ?? "")) { - genParts.push(tokens[i]!); - i++; - } - result.generation = genParts.join("."); - - // Next token should be the tier - if (i < tokens.length && CLAUDE_TIERS.has(tokens[i]?.toLowerCase() ?? "")) { - result.tier = tokens[i]!.toLowerCase(); - i++; - } - - // Remaining tokens: check for "latest" - for (; i < tokens.length; i++) { - const tok = tokens[i]?.toLowerCase() ?? ""; - if (tok === "latest" || tok === "default") { - // skip - } - } - } else { - // Legacy: "instant", "v2", etc. - if (firstToken === "instant") { - result.tier = "instant"; - result.generation = "1"; - } else if (firstToken.startsWith("v")) { - result.generation = firstToken.slice(1); - } - } - - return result; -} - -/** - * Parse a GPT-family model name. - * Handles: gpt-{GEN}[-{TIER}][-{DATE}], gpt-oss-{SIZE}b, gpt-oss-safeguard-{SIZE}b - */ -function parseGptFamily(family: string, rest: string): ParsedBareId { - const result: ParsedBareId = { - family, - generation: "", - }; - - if (family === "gpt-oss-safeguard") { - result.isOpenSource = true; - result.isSafety = true; - } else if (family === "gpt-oss") { - result.isOpenSource = true; - } - - let working = rest; - - // Extract ISO date (OpenAI uses YYYY-MM-DD) - const isoDate = extractDateISO(working); - if (isoDate) { - result.date = isoDate.date; - working = isoDate.rest; - } - - // Extract 8-digit date - if (!result.date) { - const d8 = extractDate8(working); - if (d8) { - result.date = d8.date; - working = d8.rest; - } - } - - // Extract parameter size (NNb or NNNb) - const sizeMatch = working.match(/(\d+)b(?:\b|$|-)/i); - if (sizeMatch && sizeMatch[1] !== undefined) { - result.sizeBillions = parseInt(sizeMatch[1]); - working = working.replace(sizeMatch[0], sizeMatch[0].endsWith("-") ? "" : ""); - working = working.replace(/^-|-$/g, ""); - } - - // For gpt-oss variants, the generation comes from the parent "gpt" family - // but the rest after stripping the family is the size, not a generation - if (family === "gpt-oss" || family === "gpt-oss-safeguard") { - // No generation for oss models — the size IS the distinguishing feature - // But strip trailing deployment version like "-1" on Bedrock (e.g., gpt-oss-20b-1) - working = working.replace(/-\d+$/, ""); - return result; - } - - const tokens = working.split(/[-_]/).filter((t) => t.length > 0); - - // First token is usually the generation (may have dots: "4.1", "5.2", or letters: "4o") - if (tokens.length > 0 && tokens[0] !== undefined) { - const genToken = tokens[0]; - // Check if it's a generation (starts with digit or is like "4o") - if (/^\d/.test(genToken)) { - result.generation = genToken; - - // Remaining tokens: tiers and variants - for (let i = 1; i < tokens.length; i++) { - const tok = tokens[i]?.toLowerCase() ?? ""; - if (SIZE_TIERS.has(tok)) { - // Could be a compound tier like "codex-mini" — check next token - if (result.tier === undefined) { - result.tier = tok; - } else { - // If we already have a tier, this could be a compound: e.g., "codex-mini" - // In that case, the first was a variant, fix it - if (result.variant === undefined && !SIZE_TIERS.has(result.tier)) { - result.variant = result.tier; - result.tier = tok; - } - } - } else if (VARIANTS.has(tok)) { - result.variant = result.variant ? `${result.variant}-${tok}` : tok; - } else if (tok === "latest") { - // skip - } else if (tok === "spark") { - result.variant = result.variant ? `${result.variant}-${tok}` : tok; - } else if (tok === "plus") { - result.variant = result.variant ? `${result.variant}-plus` : "plus"; - } - } - } - } - - // Special: handle "codex" as a variant, not a tier - if (result.tier === "codex" || result.tier === "chat") { - result.variant = result.tier; - result.tier = undefined; - } - - // Handle compound variants like gpt-5.1-codex-mini → variant=codex, tier=mini - if (result.variant === "codex" && tokens.length > 2) { - const lastTok = tokens[tokens.length - 1]?.toLowerCase() ?? ""; - if (SIZE_TIERS.has(lastTok) && lastTok !== "codex") { - result.tier = lastTok; - } - } - - return result; -} - -/** - * Parse Gemini model names. - * Format: gemini-{GEN}-{TIER}[-{VARIANT}][-{DATE}] - * Tiers: flash, flash-lite, pro, embedding - */ -function parseGemini(rest: string): ParsedBareId { - const result: ParsedBareId = { - family: "gemini", - generation: "", - }; - - let working = rest; - - // Extract dates (various formats in Vertex) - const d8 = extractDate8(working); - if (d8) { - result.date = d8.date; - working = d8.rest; - } - - // Check for "latest" alias (e.g., "flash-latest", "flash-lite-latest") - working = working.replace(/-latest$/, ""); - - // Check for preview with date like "preview-04-17" or "preview-09-2025" - const previewDateMatch = working.match(/-(preview)(?:-(\d{2})-(\d{2,4}))?$/); - if (previewDateMatch) { - result.isPreview = true; - // If there's a date suffix, extract it - if (previewDateMatch[2] !== undefined && previewDateMatch[3] !== undefined) { - // Keep the date as part of the identity for specific preview builds - // But don't set result.date — it's a preview date, not a release date - } - working = working.slice(0, working.length - previewDateMatch[0].length); - } - - const tokens = working.split(/[-_]/).filter((t) => t.length > 0); - - // First token should be the generation - if (tokens.length > 0 && tokens[0] !== undefined && /^\d/.test(tokens[0])) { - result.generation = tokens[0]; - - // Remaining: tier and sub-tier - const remaining = tokens.slice(1); - const tierParts: string[] = []; - for (const tok of remaining) { - const lower = tok.toLowerCase(); - if (lower === "flash" || lower === "lite" || lower === "pro" || lower === "embedding") { - tierParts.push(lower); - } else if (lower === "001") { - // Version suffix, ignore - } - } - - if (tierParts.length > 0) { - result.tier = tierParts.join("-"); // "flash", "flash-lite", "pro" - } - } else { - // No generation: "gemini-flash-latest" → tier=flash - if (tokens.length > 0) { - const tierParts: string[] = []; - for (const tok of tokens) { - const lower = tok.toLowerCase(); - if (lower === "flash" || lower === "lite" || lower === "pro") { - tierParts.push(lower); - } - } - if (tierParts.length > 0) { - result.tier = tierParts.join("-"); - } - } - } - - return result; -} - -/** - * Parse Llama model names. - * Bedrock: llama{GEN}[-{MINOR}]-{SIZE}b-instruct - * Azure: [meta-]llama-{GEN}[-{SIZE}b][-variant]-instruct - */ -function parseLlama(rest: string): ParsedBareId { - const result: ParsedBareId = { - family: "llama", - generation: "", - }; - - let working = rest; - - // Strip "meta-" prefix if present (Azure sometimes has it) - working = working.replace(/^meta-?/i, ""); - // Strip "llama-" if doubled (from "meta-llama-3.1...") - working = working.replace(/^llama-?/i, ""); - - // Normalize versions: dots to consistent format - const tokens = working.split(/[-_]/).filter((t) => t.length > 0); - - // Collect generation parts (leading digits) - const genParts: string[] = []; - let i = 0; - // First token might contain dot version like "3.1" - if (tokens[0] !== undefined && /^\d/.test(tokens[0])) { - // Could be "3.1" or "3" or "4" - if (tokens[0].includes(".")) { - genParts.push(tokens[0]); - i = 1; - } else { - genParts.push(tokens[0]); - i = 1; - // Check if next token is a pure digit (minor version in dash format) - while (i < tokens.length) { - const tok = tokens[i]; - if (tok === undefined || !/^\d+$/.test(tok) || parseInt(tok) >= 10) break; - genParts.push(tok); - i++; - } - } - } - - result.generation = genParts.length > 1 ? genParts.join(".") : (genParts[0] ?? ""); - - // Remaining tokens: look for size, variant, tier - for (; i < tokens.length; i++) { - const tok = tokens[i]?.toLowerCase() ?? ""; - - // Size: NNb - const sMatch = tok.match(/^(\d+)b$/i); - if (sMatch && sMatch[1] !== undefined) { - result.sizeBillions = parseInt(sMatch[1]); - continue; - } - - // Expert count: NNe - if (/^\d+e$/i.test(tok)) continue; // ignore expert count - - // Quantization: fp8, fp16 - const qMatch = tok.match(/^fp(\d+)$/i); - if (qMatch) { - result.quantization = tok.toLowerCase(); - continue; - } - - // Context: NNk - const ctxMatch = tok.match(/^(\d+)k$/i); - if (ctxMatch && ctxMatch[1] !== undefined) { - result.contextK = parseInt(ctxMatch[1]); - continue; - } - - // Known variants - if (tok === "instruct" || tok === "vision" || tok === "it") { - result.variant = result.variant ? `${result.variant}-${tok}` : tok; - continue; - } - - // Named variants: scout, maverick - if (tok === "scout" || tok === "maverick") { - result.tier = tok; - continue; - } - - // API version: v1, v2 - if (/^v\d+$/.test(tok)) continue; - } - - return result; -} - -/** - * Parse O-series model names (o1, o3, o4-mini). - */ -function parseOSeries(rest: string): ParsedBareId { - const result: ParsedBareId = { - family: "o", - generation: "", - }; - - const tokens = rest.split(/[-_]/).filter((t) => t.length > 0); - - // First token is the generation number - if (tokens[0] !== undefined && /^\d+$/.test(tokens[0])) { - result.generation = tokens[0]; - } - - // Remaining: tier and variant - for (let i = 1; i < tokens.length; i++) { - const tok = tokens[i]?.toLowerCase() ?? ""; - if (SIZE_TIERS.has(tok)) { - result.tier = tok; - } else if (tok === "preview") { - result.isPreview = true; - } else if (VARIANTS.has(tok) || tok === "deep-research") { - result.variant = result.variant ? `${result.variant}-${tok}` : tok; - } else if (tok === "deep") { - // Start of "deep-research" compound - const next = tokens[i + 1]?.toLowerCase() ?? ""; - if (next === "research") { - result.variant = "deep-research"; - i++; - } - } - } - - return result; -} - -/** - * Generic parser for model families that follow a straightforward pattern: - * {FAMILY}-{GEN}[-{TIER}][-{SIZE}b][-{VARIANT}][-{DATE}] - * - * Used for: grok, glm, phi, mistral, deepseek, qwen, jamba, nova, etc. - */ -function parseGeneric(family: string, rest: string): ParsedBareId { - const result: ParsedBareId = { - family, - generation: "", - }; - - let working = rest; - - // Extract 8-digit date - const d8 = extractDate8(working); - if (d8) { - result.date = d8.date; - working = d8.rest; - } - - // Extract 4-digit date (YYMM or MMDD — ambiguous, store as-is) - // Only if it appears at the end or followed by a delimiter - const d4Match = working.match(/(?:^|-)(\d{4})(?:$|-)/); - if (d4Match && !result.date && d4Match[1] !== undefined) { - // Distinguish dates from versions: dates are usually 4 digits at the end - // For Mistral: 2402, 2411, 2503, 2505 are dates (YYMM) - const val = parseInt(d4Match[1]); - if (val > 2000 && val < 2600) { - // Likely a YYMM date - result.date = d4Match[1]; - const idx = d4Match.index ?? 0; - const prefix = idx > 0 ? working.slice(0, idx) : ""; - const suffix = working.slice(idx + d4Match[0].length); - working = [prefix, suffix].filter(Boolean).join("-").replace(/^-|-$/g, ""); - } - } - - // Extract parameter size: NNb (but not in the middle of a word) - const sizeMatch = working.match(/(?:^|-)(\d+)b(?:$|-)/i); - if (sizeMatch && sizeMatch[1] !== undefined) { - result.sizeBillions = parseInt(sizeMatch[1]); - working = working.replace(new RegExp(`-?${sizeMatch[1]}b-?`, "i"), "-").replace(/^-|-$/g, ""); - } - - // Extract active params for MoE: aNNb - const activeMatch = working.match(/(?:^|-)a(\d+)b(?:$|-)/i); - if (activeMatch && activeMatch[1] !== undefined) { - result.activeBillions = parseInt(activeMatch[1]); - working = working - .replace(new RegExp(`-?a${activeMatch[1]}b-?`, "i"), "-") - .replace(/^-|-$/g, ""); - } - - // Extract context: NNk - const ctxMatch = working.match(/(?:^|-)(\d+)k(?:$|-)/i); - if (ctxMatch && ctxMatch[1] !== undefined) { - result.contextK = parseInt(ctxMatch[1]); - working = working.replace(new RegExp(`-?${ctxMatch[1]}k-?`, "i"), "-").replace(/^-|-$/g, ""); - } - - // Extract quantization: fpNN - const quantMatch = working.match(/(?:^|-)fp(\d+)(?:$|-)/i); - if (quantMatch && quantMatch[1] !== undefined) { - result.quantization = `fp${quantMatch[1]}`; - working = working.replace(new RegExp(`-?fp${quantMatch[1]}-?`, "i"), "-").replace(/^-|-$/g, ""); - } - - // Strip version suffix: -vN (Bedrock API version) - working = working.replace(/-v\d+$/, ""); - - const tokens = working.split(/[-_]/).filter((t) => t.length > 0); - - // First token: generation (if it starts with a digit, or is a prefixed version like v3.2, m2.1, r1, k2.5) - let i = 0; - if (tokens[0] !== undefined && /^\d/.test(tokens[0])) { - // Could be "3.5", "4.1", "2.0", "3", etc. - result.generation = tokens[0]; - i = 1; - - // Check for dash-separated minor version - const minorTok = tokens[i]; - if (minorTok !== undefined && /^\d+$/.test(minorTok) && parseInt(minorTok) < 10) { - result.generation = `${result.generation}.${minorTok}`; - i++; - } - } else if (tokens[0] !== undefined && /^[vmrk]\d[\d.]*$/i.test(tokens[0])) { - // Prefixed version identifiers: v3.2, m2.1, r1, k2.5 - // Keep the prefix as part of the generation to distinguish e.g. v3 from r1 - result.generation = tokens[0].toLowerCase(); - i = 1; - } - - // Remaining tokens: classify as tier, variant, or skip - for (; i < tokens.length; i++) { - const tok = tokens[i]?.toLowerCase() ?? ""; - - if (SIZE_TIERS.has(tok)) { - if (result.tier === undefined) { - result.tier = tok; - } - } else if (VARIANTS.has(tok)) { - result.variant = result.variant ? `${result.variant}-${tok}` : tok; - } else if (tok === "preview") { - result.isPreview = true; - } else if (tok === "latest" || tok === "default" || tok === "text") { - // skip metadata - } else if (tok === "safeguard" || tok === "safety") { - result.isSafety = true; - } else if (tok === "oss") { - result.isOpenSource = true; - } else if (tok === "plus") { - // Modifier on variant: "reasoning-plus", "command-r-plus" - if (result.variant) { - result.variant = `${result.variant}-plus`; - } else if (result.tier) { - result.tier = `${result.tier}-plus`; - } - } else if (tok === "next") { - result.variant = result.variant ? `${result.variant}-next` : "next"; - } else if (tok === "light") { - result.tier = "light"; - } else if (/^\d[\d.]*$/.test(tok) && result.generation === "") { - // A numeric token appearing after variants/tiers when no generation - // was found yet (e.g., "grok-code" → rest="fast-1" → generation="1") - result.generation = tok; - } - } - - return result; -} - -function parseBareId(bareId: string): ParsedBareId { - const detected = detectFamily(bareId); - if (!detected) { - return { family: bareId.toLowerCase(), generation: "" }; - } - - const { family, rest } = detected; - - switch (family) { - case "claude": - return parseClaude(rest); - - case "gpt": - case "gpt-oss": - case "gpt-oss-safeguard": - return parseGptFamily(family, rest); - - case "gemini": - return parseGemini(rest); - - case "meta-llama": - case "llama": - return parseLlama(rest); - - case "o": - return parseOSeries(rest); - - default: - return parseGeneric(family, rest); - } -} - -export function parseModelId(raw: string, platform: string): ParsedModelId { - const stripped = stripProviderWrapper(raw, platform); - const parsed = parseBareId(stripped.bareId); - - return { - raw, - platform, - - // Provider wrapper metadata - region: stripped.region, - vendor: stripped.vendor, - deploymentVersion: stripped.deploymentVersion, - routingTag: stripped.routingTag, - maasSuffix: stripped.maasSuffix, - - // Core features - family: parsed.family, - generation: parsed.generation, - tier: parsed.tier, - variant: parsed.variant, - - // Secondary features - sizeBillions: parsed.sizeBillions, - activeBillions: parsed.activeBillions, - date: parsed.date, - contextK: parsed.contextK, - quantization: parsed.quantization, - - // Flags - isOpenSource: parsed.isOpenSource, - isSafety: parsed.isSafety, - isPreview: parsed.isPreview, - }; -} diff --git a/packages/resolver/src/parser/parse_model_id/constants.ts b/packages/resolver/src/parser/parse_model_id/constants.ts new file mode 100644 index 0000000..d159732 --- /dev/null +++ b/packages/resolver/src/parser/parse_model_id/constants.ts @@ -0,0 +1,81 @@ +export const REGIONS = new Set(["us", "eu", "ap", "global"]); + +export const CLAUDE_TIERS = new Set(["opus", "sonnet", "haiku", "instant"]); + +export const SIZE_TIERS = new Set([ + // Quality tiers (Claude) + "opus", + "sonnet", + "haiku", + "instant", + // Size tiers (GPT, Phi, Mistral, etc.) + "nano", + "micro", + "mini", + "small", + "medium", + "large", + // Speed/cost tiers (Gemini, Amazon Nova) + "lite", + "flash", + "express", + "turbo", + // Premium tiers + "pro", + "premier", + "max", +]); + +export const VARIANTS = new Set([ + "instruct", + "chat", + "codex", + "coder", + "code", + "vision", + "vl", + "multimodal", + "thinking", + "reasoning", + "fast", + "it", // Google's "instruction-tuned" abbreviation + "moe", + "deep-research", +]); + +export const FAMILY_ROOTS = [ + "gpt-oss-safeguard", + "gpt-oss", + "gpt", + "claude", + "gemini", + "gemma", + "meta-llama", + "llama", + "grok-code", + "grok", + "glm", + "phi", + "mistral", + "mixtral", + "ministral", + "voxtral", + "codestral", + "deepseek", + "command-r-plus", + "command-r", + "command", + "jamba", + "titan", + "nova", + "nemotron", + "palmyra", + "minimax", + "kimi", + "qwen", + "lfm", + "step", + "trinity", + "pony", + "o", // OpenAI o-series (o1, o3, o4-mini) — must be last, very short +]; diff --git a/packages/resolver/src/parser/parse_model_id/families/claude.ts b/packages/resolver/src/parser/parse_model_id/families/claude.ts new file mode 100644 index 0000000..8d60215 --- /dev/null +++ b/packages/resolver/src/parser/parse_model_id/families/claude.ts @@ -0,0 +1,83 @@ +import type { ParsedBareId } from "../types"; +import { CLAUDE_TIERS } from "../constants"; +import { extractDate8 } from "../helpers"; + +/** + * Parse Claude-specific model names, handling the gen3/gen4+ naming inversion. + * + * Gen 3 scheme: claude-{GEN}-{TIER}-{DATE}-{API_VER} + * e.g. "3-5-sonnet-20240620" + * + * Gen 4+ scheme: claude-{TIER}-{GEN}-{DATE}-{API_VER} + * e.g. "sonnet-4-5-20250929" + */ +export function parseClaude(rest: string): ParsedBareId { + const result: ParsedBareId = { + family: "claude", + generation: "", + }; + + let working = rest; + const dateResult = extractDate8(working); + if (dateResult) { + result.date = dateResult.date; + working = dateResult.rest; + } + + const tokens = working.split(/[-_]/).filter((t) => t.length > 0); + const firstToken = tokens[0]?.toLowerCase() ?? ""; + + if (CLAUDE_TIERS.has(firstToken)) { + // Gen 4+ scheme: {TIER}-{GEN_MAJOR}[-{GEN_MINOR}]-... + result.tier = firstToken; + + const genParts: string[] = []; + let i = 1; + while (i < tokens.length && /^\d[\d.]*$/.test(tokens[i] ?? "")) { + genParts.push(tokens[i]!); + i++; + } + + if (genParts.length > 0) { + result.generation = genParts.join("."); + } + + for (; i < tokens.length; i++) { + const tok = tokens[i]?.toLowerCase() ?? ""; + if (tok === "latest" || tok === "default") { + // skip — these are aliases + } + } + } else if (/^\d+$/.test(firstToken)) { + // Gen 3 scheme: {GEN_MAJOR}[-{GEN_MINOR}]-{TIER}-... + const genParts: string[] = [firstToken]; + let i = 1; + while (i < tokens.length && /^\d+$/.test(tokens[i] ?? "")) { + genParts.push(tokens[i]!); + i++; + } + result.generation = genParts.join("."); + + if (i < tokens.length && CLAUDE_TIERS.has(tokens[i]?.toLowerCase() ?? "")) { + result.tier = tokens[i]!.toLowerCase(); + i++; + } + + for (; i < tokens.length; i++) { + const tok = tokens[i]?.toLowerCase() ?? ""; + if (tok === "latest" || tok === "default") { + // skip + } + } + } else { + // Legacy: "instant", "v2", etc. + if (firstToken === "instant") { + result.tier = "instant"; + result.generation = "1"; + } else if (firstToken.startsWith("v")) { + result.generation = firstToken.slice(1); + } + } + + return result; +} diff --git a/packages/resolver/src/parser/parse_model_id/families/gemini.ts b/packages/resolver/src/parser/parse_model_id/families/gemini.ts new file mode 100644 index 0000000..584305e --- /dev/null +++ b/packages/resolver/src/parser/parse_model_id/families/gemini.ts @@ -0,0 +1,66 @@ +import type { ParsedBareId } from "../types"; +import { extractDate8 } from "../helpers"; + +/** + * Parse Gemini model names. + * Format: gemini-{GEN}-{TIER}[-{VARIANT}][-{DATE}] + * Tiers: flash, flash-lite, pro, embedding + */ +export function parseGemini(rest: string): ParsedBareId { + const result: ParsedBareId = { + family: "gemini", + generation: "", + }; + + let working = rest; + + const d8 = extractDate8(working); + if (d8) { + result.date = d8.date; + working = d8.rest; + } + + working = working.replace(/-latest$/, ""); + + const previewDateMatch = working.match(/-(preview)(?:-(\d{2})-(\d{2,4}))?$/); + if (previewDateMatch) { + result.isPreview = true; + working = working.slice(0, working.length - previewDateMatch[0].length); + } + + const tokens = working.split(/[-_]/).filter((t) => t.length > 0); + + if (tokens.length > 0 && tokens[0] !== undefined && /^\d/.test(tokens[0])) { + result.generation = tokens[0]; + + const remaining = tokens.slice(1); + const tierParts: string[] = []; + for (const tok of remaining) { + const lower = tok.toLowerCase(); + if (lower === "flash" || lower === "lite" || lower === "pro" || lower === "embedding") { + tierParts.push(lower); + } else if (lower === "001") { + // Version suffix, ignore + } + } + + if (tierParts.length > 0) { + result.tier = tierParts.join("-"); + } + } else { + if (tokens.length > 0) { + const tierParts: string[] = []; + for (const tok of tokens) { + const lower = tok.toLowerCase(); + if (lower === "flash" || lower === "lite" || lower === "pro") { + tierParts.push(lower); + } + } + if (tierParts.length > 0) { + result.tier = tierParts.join("-"); + } + } + } + + return result; +} diff --git a/packages/resolver/src/parser/parse_model_id/families/generic.ts b/packages/resolver/src/parser/parse_model_id/families/generic.ts new file mode 100644 index 0000000..b5b4e21 --- /dev/null +++ b/packages/resolver/src/parser/parse_model_id/families/generic.ts @@ -0,0 +1,123 @@ +import type { ParsedBareId } from "../types"; +import { SIZE_TIERS, VARIANTS } from "../constants"; +import { extractDate8 } from "../helpers"; + +/** + * Generic parser for model families that follow a straightforward pattern: + * {FAMILY}-{GEN}[-{TIER}][-{SIZE}b][-{VARIANT}][-{DATE}] + * + * Used for: grok, glm, phi, mistral, deepseek, qwen, jamba, nova, etc. + */ +export function parseGeneric(family: string, rest: string): ParsedBareId { + const result: ParsedBareId = { + family, + generation: "", + }; + + let working = rest; + + const d8 = extractDate8(working); + if (d8) { + result.date = d8.date; + working = d8.rest; + } + + // Extract 4-digit date (YYMM) — only if no 8-digit date found + const d4Match = working.match(/(?:^|-)(\d{4})(?:$|-)/); + if (d4Match && !result.date && d4Match[1] !== undefined) { + const val = parseInt(d4Match[1]); + if (val > 2000 && val < 2600) { + result.date = d4Match[1]; + const idx = d4Match.index ?? 0; + const prefix = idx > 0 ? working.slice(0, idx) : ""; + const suffix = working.slice(idx + d4Match[0].length); + working = [prefix, suffix].filter(Boolean).join("-").replace(/^-|-$/g, ""); + } + } + + // Extract parameter size: NNb + const sizeMatch = working.match(/(?:^|-)(\d+)b(?:$|-)/i); + if (sizeMatch && sizeMatch[1] !== undefined) { + result.sizeBillions = parseInt(sizeMatch[1]); + working = working.replace(new RegExp(`-?${sizeMatch[1]}b-?`, "i"), "-").replace(/^-|-$/g, ""); + } + + // Extract active params for MoE: aNNb + const activeMatch = working.match(/(?:^|-)a(\d+)b(?:$|-)/i); + if (activeMatch && activeMatch[1] !== undefined) { + result.activeBillions = parseInt(activeMatch[1]); + working = working + .replace(new RegExp(`-?a${activeMatch[1]}b-?`, "i"), "-") + .replace(/^-|-$/g, ""); + } + + // Extract context: NNk + const ctxMatch = working.match(/(?:^|-)(\d+)k(?:$|-)/i); + if (ctxMatch && ctxMatch[1] !== undefined) { + result.contextK = parseInt(ctxMatch[1]); + working = working.replace(new RegExp(`-?${ctxMatch[1]}k-?`, "i"), "-").replace(/^-|-$/g, ""); + } + + // Extract quantization: fpNN + const quantMatch = working.match(/(?:^|-)fp(\d+)(?:$|-)/i); + if (quantMatch && quantMatch[1] !== undefined) { + result.quantization = `fp${quantMatch[1]}`; + working = working.replace(new RegExp(`-?fp${quantMatch[1]}-?`, "i"), "-").replace(/^-|-$/g, ""); + } + + // Strip version suffix: -vN + working = working.replace(/-v\d+$/, ""); + + const tokens = working.split(/[-_]/).filter((t) => t.length > 0); + + // First token: generation + let i = 0; + if (tokens[0] !== undefined && /^\d/.test(tokens[0])) { + result.generation = tokens[0]; + i = 1; + + const minorTok = tokens[i]; + if (minorTok !== undefined && /^\d+$/.test(minorTok) && parseInt(minorTok) < 10) { + result.generation = `${result.generation}.${minorTok}`; + i++; + } + } else if (tokens[0] !== undefined && /^[vmrk]\d[\d.]*$/i.test(tokens[0])) { + result.generation = tokens[0].toLowerCase(); + i = 1; + } + + // Remaining tokens: classify as tier, variant, or skip + for (; i < tokens.length; i++) { + const tok = tokens[i]?.toLowerCase() ?? ""; + + if (SIZE_TIERS.has(tok)) { + if (result.tier === undefined) { + result.tier = tok; + } + } else if (VARIANTS.has(tok)) { + result.variant = result.variant ? `${result.variant}-${tok}` : tok; + } else if (tok === "preview") { + result.isPreview = true; + } else if (tok === "latest" || tok === "default" || tok === "text") { + // skip metadata + } else if (tok === "safeguard" || tok === "safety") { + result.isSafety = true; + } else if (tok === "oss") { + result.isOpenSource = true; + } else if (tok === "plus") { + if (result.variant) { + result.variant = `${result.variant}-plus`; + } else if (result.tier) { + result.tier = `${result.tier}-plus`; + } + } else if (tok === "next") { + result.variant = result.variant ? `${result.variant}-next` : "next"; + } else if (tok === "light") { + result.tier = "light"; + } else if (/^\d[\d.]*$/.test(tok) && result.generation === "") { + result.generation = tok; + } + } + + return result; +} diff --git a/packages/resolver/src/parser/parse_model_id/families/gpt.ts b/packages/resolver/src/parser/parse_model_id/families/gpt.ts new file mode 100644 index 0000000..25fdeb6 --- /dev/null +++ b/packages/resolver/src/parser/parse_model_id/families/gpt.ts @@ -0,0 +1,94 @@ +import type { ParsedBareId } from "../types"; +import { SIZE_TIERS, VARIANTS } from "../constants"; +import { extractDate8, extractDateISO } from "../helpers"; + +/** + * Parse a GPT-family model name. + * Handles: gpt-{GEN}[-{TIER}][-{DATE}], gpt-oss-{SIZE}b, gpt-oss-safeguard-{SIZE}b + */ +export function parseGptFamily(family: string, rest: string): ParsedBareId { + const result: ParsedBareId = { + family, + generation: "", + }; + + if (family === "gpt-oss-safeguard") { + result.isOpenSource = true; + result.isSafety = true; + } else if (family === "gpt-oss") { + result.isOpenSource = true; + } + + let working = rest; + + const isoDate = extractDateISO(working); + if (isoDate) { + result.date = isoDate.date; + working = isoDate.rest; + } + + if (!result.date) { + const d8 = extractDate8(working); + if (d8) { + result.date = d8.date; + working = d8.rest; + } + } + + const sizeMatch = working.match(/(\d+)b(?:\b|$|-)/i); + if (sizeMatch && sizeMatch[1] !== undefined) { + result.sizeBillions = parseInt(sizeMatch[1]); + working = working.replace(sizeMatch[0], sizeMatch[0].endsWith("-") ? "" : ""); + working = working.replace(/^-|-$/g, ""); + } + + if (family === "gpt-oss" || family === "gpt-oss-safeguard") { + working = working.replace(/-\d+$/, ""); + return result; + } + + const tokens = working.split(/[-_]/).filter((t) => t.length > 0); + + if (tokens.length > 0 && tokens[0] !== undefined) { + const genToken = tokens[0]; + if (/^\d/.test(genToken)) { + result.generation = genToken; + + for (let i = 1; i < tokens.length; i++) { + const tok = tokens[i]?.toLowerCase() ?? ""; + if (SIZE_TIERS.has(tok)) { + if (result.tier === undefined) { + result.tier = tok; + } else { + if (result.variant === undefined && !SIZE_TIERS.has(result.tier)) { + result.variant = result.tier; + result.tier = tok; + } + } + } else if (VARIANTS.has(tok)) { + result.variant = result.variant ? `${result.variant}-${tok}` : tok; + } else if (tok === "latest") { + // skip + } else if (tok === "spark") { + result.variant = result.variant ? `${result.variant}-${tok}` : tok; + } else if (tok === "plus") { + result.variant = result.variant ? `${result.variant}-plus` : "plus"; + } + } + } + } + + if (result.tier === "codex" || result.tier === "chat") { + result.variant = result.tier; + result.tier = undefined; + } + + if (result.variant === "codex" && tokens.length > 2) { + const lastTok = tokens[tokens.length - 1]?.toLowerCase() ?? ""; + if (SIZE_TIERS.has(lastTok) && lastTok !== "codex") { + result.tier = lastTok; + } + } + + return result; +} diff --git a/packages/resolver/src/parser/parse_model_id/families/llama.ts b/packages/resolver/src/parser/parse_model_id/families/llama.ts new file mode 100644 index 0000000..242ba17 --- /dev/null +++ b/packages/resolver/src/parser/parse_model_id/families/llama.ts @@ -0,0 +1,78 @@ +import type { ParsedBareId } from "../types"; + +/** + * Parse Llama model names. + * Bedrock: llama{GEN}[-{MINOR}]-{SIZE}b-instruct + * Azure: [meta-]llama-{GEN}[-{SIZE}b][-variant]-instruct + */ +export function parseLlama(rest: string): ParsedBareId { + const result: ParsedBareId = { + family: "llama", + generation: "", + }; + + let working = rest; + + working = working.replace(/^meta-?/i, ""); + working = working.replace(/^llama-?/i, ""); + + const tokens = working.split(/[-_]/).filter((t) => t.length > 0); + + const genParts: string[] = []; + let i = 0; + if (tokens[0] !== undefined && /^\d/.test(tokens[0])) { + if (tokens[0].includes(".")) { + genParts.push(tokens[0]); + i = 1; + } else { + genParts.push(tokens[0]); + i = 1; + while (i < tokens.length) { + const tok = tokens[i]; + if (tok === undefined || !/^\d+$/.test(tok) || parseInt(tok) >= 10) break; + genParts.push(tok); + i++; + } + } + } + + result.generation = genParts.length > 1 ? genParts.join(".") : (genParts[0] ?? ""); + + for (; i < tokens.length; i++) { + const tok = tokens[i]?.toLowerCase() ?? ""; + + const sMatch = tok.match(/^(\d+)b$/i); + if (sMatch && sMatch[1] !== undefined) { + result.sizeBillions = parseInt(sMatch[1]); + continue; + } + + if (/^\d+e$/i.test(tok)) continue; + + const qMatch = tok.match(/^fp(\d+)$/i); + if (qMatch) { + result.quantization = tok.toLowerCase(); + continue; + } + + const ctxMatch = tok.match(/^(\d+)k$/i); + if (ctxMatch && ctxMatch[1] !== undefined) { + result.contextK = parseInt(ctxMatch[1]); + continue; + } + + if (tok === "instruct" || tok === "vision" || tok === "it") { + result.variant = result.variant ? `${result.variant}-${tok}` : tok; + continue; + } + + if (tok === "scout" || tok === "maverick") { + result.tier = tok; + continue; + } + + if (/^v\d+$/.test(tok)) continue; + } + + return result; +} diff --git a/packages/resolver/src/parser/parse_model_id/families/o_series.ts b/packages/resolver/src/parser/parse_model_id/families/o_series.ts new file mode 100644 index 0000000..008fb7a --- /dev/null +++ b/packages/resolver/src/parser/parse_model_id/families/o_series.ts @@ -0,0 +1,37 @@ +import type { ParsedBareId } from "../types"; +import { SIZE_TIERS, VARIANTS } from "../constants"; + +/** + * Parse O-series model names (o1, o3, o4-mini). + */ +export function parseOSeries(rest: string): ParsedBareId { + const result: ParsedBareId = { + family: "o", + generation: "", + }; + + const tokens = rest.split(/[-_]/).filter((t) => t.length > 0); + + if (tokens[0] !== undefined && /^\d+$/.test(tokens[0])) { + result.generation = tokens[0]; + } + + for (let i = 1; i < tokens.length; i++) { + const tok = tokens[i]?.toLowerCase() ?? ""; + if (SIZE_TIERS.has(tok)) { + result.tier = tok; + } else if (tok === "preview") { + result.isPreview = true; + } else if (VARIANTS.has(tok) || tok === "deep-research") { + result.variant = result.variant ? `${result.variant}-${tok}` : tok; + } else if (tok === "deep") { + const next = tokens[i + 1]?.toLowerCase() ?? ""; + if (next === "research") { + result.variant = "deep-research"; + i++; + } + } + } + + return result; +} diff --git a/packages/resolver/src/parser/parse_model_id/helpers.ts b/packages/resolver/src/parser/parse_model_id/helpers.ts new file mode 100644 index 0000000..000b163 --- /dev/null +++ b/packages/resolver/src/parser/parse_model_id/helpers.ts @@ -0,0 +1,27 @@ +export function dotVersionToDash(s: string): string { + return s.replace(/(\d+)\.(\d+)/g, "$1-$2"); +} + +export function extractDate8(s: string): { date: string; rest: string } | null { + const m = s.match(/(^|[-_])(\d{8})($|[-_])/); + if (!m || m[2] === undefined) return null; + const year = parseInt(m[2].slice(0, 4)); + if (year < 2020 || year > 2030) return null; + const start = (m.index ?? 0) + (m[1]?.length ?? 0); + const end = start + 8; + const rest = s.slice(0, start > 0 ? start - 1 : start) + s.slice(end); + return { date: m[2], rest: rest.replace(/^-|-$/g, "") }; +} + +export function extractDateISO(s: string): { date: string; rest: string } | null { + const m = s.match(/(^|[-_])(\d{4})-(\d{2})-(\d{2})($|[-_])/); + if (!m || m[2] === undefined || m[3] === undefined || m[4] === undefined) return null; + const year = parseInt(m[2]); + if (year < 2020 || year > 2030) return null; + const date = `${m[2]}${m[3]}${m[4]}`; + const full = `${m[2]}-${m[3]}-${m[4]}`; + const start = (m.index ?? 0) + (m[1]?.length ?? 0); + const end = start + full.length; + const rest = s.slice(0, start > 0 ? start - 1 : start) + s.slice(end); + return { date, rest: rest.replace(/^-|-$/g, "") }; +} diff --git a/packages/resolver/src/parser/parse_model_id/index.ts b/packages/resolver/src/parser/parse_model_id/index.ts new file mode 100644 index 0000000..78d5497 --- /dev/null +++ b/packages/resolver/src/parser/parse_model_id/index.ts @@ -0,0 +1,123 @@ +import type { ParsedBareId, ParsedModelId } from "./types"; +import { FAMILY_ROOTS } from "./constants"; +import { dotVersionToDash } from "./helpers"; +import { stripProviderWrapper } from "./strip"; +import { parseClaude } from "./families/claude"; +import { parseGptFamily } from "./families/gpt"; +import { parseGemini } from "./families/gemini"; +import { parseLlama } from "./families/llama"; +import { parseOSeries } from "./families/o_series"; +import { parseGeneric } from "./families/generic"; + +export type { ParsedModelId } from "./types"; + +/** + * Detect the family root from a bare model ID. + * Returns the family name and the remaining string after the family prefix. + */ +function detectFamily(bareId: string): { family: string; rest: string } | null { + const lower = bareId.toLowerCase(); + + for (const root of FAMILY_ROOTS) { + if (root === "o") { + const m = lower.match(/^o(\d)/); + if (m) { + return { family: "o", rest: bareId.slice(1) }; + } + continue; + } + + const normalized = dotVersionToDash(lower); + const rootDashed = dotVersionToDash(root); + if ( + normalized.startsWith(rootDashed) && + (normalized.length === rootDashed.length || + normalized[rootDashed.length] === "-" || + normalized[rootDashed.length] === "_" || + /\d/.test(normalized[rootDashed.length] ?? "")) + ) { + let restStart = root.length; + const origPrefix = bareId.slice(0, rootDashed.length); + if (dotVersionToDash(origPrefix.toLowerCase()) === rootDashed) { + restStart = rootDashed.length; + } + + let rest = bareId.slice(restStart); + if (rest.startsWith("-") || rest.startsWith("_")) { + rest = rest.slice(1); + } + return { family: root, rest }; + } + } + + // Fallback: first token is the family + const firstDelim = bareId.search(/[-_]/); + if (firstDelim === -1) return { family: bareId.toLowerCase(), rest: "" }; + return { + family: bareId.slice(0, firstDelim).toLowerCase(), + rest: bareId.slice(firstDelim + 1), + }; +} + +function parseBareId(bareId: string): ParsedBareId { + const detected = detectFamily(bareId); + if (!detected) { + return { family: bareId.toLowerCase(), generation: "" }; + } + + const { family, rest } = detected; + + switch (family) { + case "claude": + return parseClaude(rest); + + case "gpt": + case "gpt-oss": + case "gpt-oss-safeguard": + return parseGptFamily(family, rest); + + case "gemini": + return parseGemini(rest); + + case "meta-llama": + case "llama": + return parseLlama(rest); + + case "o": + return parseOSeries(rest); + + default: + return parseGeneric(family, rest); + } +} + +export function parseModelId(raw: string, platform: string): ParsedModelId { + const stripped = stripProviderWrapper(raw, platform); + const parsed = parseBareId(stripped.bareId); + + return { + raw, + platform, + + region: stripped.region, + vendor: stripped.vendor, + deploymentVersion: stripped.deploymentVersion, + routingTag: stripped.routingTag, + maasSuffix: stripped.maasSuffix, + + family: parsed.family, + generation: parsed.generation, + tier: parsed.tier, + variant: parsed.variant, + + sizeBillions: parsed.sizeBillions, + activeBillions: parsed.activeBillions, + date: parsed.date, + contextK: parsed.contextK, + quantization: parsed.quantization, + + isOpenSource: parsed.isOpenSource, + isSafety: parsed.isSafety, + isPreview: parsed.isPreview, + }; +} diff --git a/packages/resolver/src/parser/parse_model_id/strip.ts b/packages/resolver/src/parser/parse_model_id/strip.ts new file mode 100644 index 0000000..e72de92 --- /dev/null +++ b/packages/resolver/src/parser/parse_model_id/strip.ts @@ -0,0 +1,105 @@ +import { REGIONS } from "./constants"; + +export interface StrippedResult { + bareId: string; + region?: string | undefined; + vendor?: string | undefined; + deploymentVersion?: string | undefined; + routingTag?: string | undefined; + maasSuffix?: boolean | undefined; +} + +function stripBedrock(raw: string): StrippedResult { + let id = raw; + let region: string | undefined; + let vendor: string | undefined; + let deploymentVersion: string | undefined; + + const dvMatch = id.match(/:(\d[\w:]*?)$/); + if (dvMatch && dvMatch[1] !== undefined) { + deploymentVersion = dvMatch[1]; + id = id.slice(0, id.length - dvMatch[0].length); + } + + const dotParts = id.split("."); + if (dotParts.length >= 2) { + let startIdx = 0; + + if (dotParts[0] !== undefined && REGIONS.has(dotParts[0])) { + region = dotParts[0]; + startIdx = 1; + } + + vendor = dotParts[startIdx]; + id = dotParts.slice(startIdx + 1).join("."); + } + + const apiVerMatch = id.match(/-v(\d+)$/); + if (apiVerMatch) { + if (deploymentVersion === undefined) { + deploymentVersion = `v${apiVerMatch[1]}`; + } + id = id.slice(0, id.length - apiVerMatch[0].length); + } + + return { bareId: id, region, vendor, deploymentVersion }; +} + +function stripVertexAnthropic(raw: string): StrippedResult { + const atIdx = raw.indexOf("@"); + if (atIdx === -1) return { bareId: raw }; + + const bareId = raw.slice(0, atIdx); + const routingTag = raw.slice(atIdx + 1); + return { bareId, routingTag }; +} + +function stripVertex(raw: string): StrippedResult { + let id = raw; + let vendor: string | undefined; + let maasSuffix: boolean | undefined; + + const slashIdx = id.indexOf("/"); + if (slashIdx !== -1) { + vendor = id.slice(0, slashIdx); + id = id.slice(slashIdx + 1); + } + + if (id.endsWith("-maas")) { + maasSuffix = true; + id = id.slice(0, -5); + } + + return { bareId: id, vendor, maasSuffix }; +} + +function stripOpenRouterSlug(raw: string): StrippedResult { + const slashIdx = raw.indexOf("/"); + if (slashIdx === -1) return { bareId: raw }; + + const vendor = raw.slice(0, slashIdx); + const bareId = raw.slice(slashIdx + 1); + return { bareId, vendor }; +} + +function stripMinimal(raw: string): StrippedResult { + return { bareId: raw }; +} + +export function stripProviderWrapper(raw: string, platform: string): StrippedResult { + switch (platform) { + case "amazon-bedrock": + return stripBedrock(raw); + case "google-vertex-anthropic": + return stripVertexAnthropic(raw); + case "google-vertex": + return stripVertex(raw); + case "openrouter": + return stripOpenRouterSlug(raw); + case "anthropic": + case "azure": + case "openai": + default: + return stripMinimal(raw); + } +} diff --git a/packages/resolver/src/parser/parse_model_id/types.ts b/packages/resolver/src/parser/parse_model_id/types.ts new file mode 100644 index 0000000..0591bf9 --- /dev/null +++ b/packages/resolver/src/parser/parse_model_id/types.ts @@ -0,0 +1,84 @@ +export interface ParsedModelId { + readonly raw: string; + + /** The platform/provider the model ID originates from. */ + readonly platform: string; + + /** Bedrock region prefix: "us" | "eu" | "global". */ + readonly region?: string | undefined; + + /** The upstream vendor extracted from provider-prefixed IDs. */ + readonly vendor?: string | undefined; + + /** Bedrock deployment version suffix, e.g. "v1:0". */ + readonly deploymentVersion?: string | undefined; + + /** Vertex-Anthropic routing tag, e.g. "20250514" or "default". */ + readonly routingTag?: string | undefined; + + /** Whether the "-maas" suffix was present (Google Vertex). */ + readonly maasSuffix?: boolean | undefined; + + // -- Core matching features (the canonical identity) ---------------------- + + /** Model family root: "claude", "gpt", "gemini", "llama", "grok", "glm", etc. */ + readonly family: string; + + /** + * Normalized generation string using dots: "4.5", "3.7", "2.0", "4o". + * Empty string when no generation is detected. + */ + readonly generation: string; + + /** Size/quality tier: "opus", "sonnet", "haiku", "mini", "nano", "flash", "pro", etc. */ + readonly tier?: string | undefined; + + /** + * Capability/mode variant: "codex", "chat", "coder", "fast", "instruct", + * "thinking", "reasoning", "vision", "vl", etc. + */ + readonly variant?: string | undefined; + + // -- Secondary features --------------------------------------------------- + + /** Total parameter count in billions. */ + readonly sizeBillions?: number | undefined; + + /** MoE active parameter count in billions. */ + readonly activeBillions?: number | undefined; + + /** Release/snapshot date normalized to "YYYYMMDD". */ + readonly date?: string | undefined; + + /** Context window in thousands (e.g. 128 for 128k). */ + readonly contextK?: number | undefined; + + /** Quantization format, e.g. "fp8", "fp16". */ + readonly quantization?: string | undefined; + + // -- Flags ---------------------------------------------------------------- + + /** Model marketed as open-source/open-weight (e.g. gpt-oss). */ + readonly isOpenSource?: boolean | undefined; + + /** Safety/guard/safeguard model. */ + readonly isSafety?: boolean | undefined; + + /** Explicitly a preview release. */ + readonly isPreview?: boolean | undefined; +} + +export interface ParsedBareId { + family: string; + generation: string; + tier?: string | undefined; + variant?: string | undefined; + sizeBillions?: number | undefined; + activeBillions?: number | undefined; + date?: string | undefined; + contextK?: number | undefined; + quantization?: string | undefined; + isOpenSource?: boolean | undefined; + isSafety?: boolean | undefined; + isPreview?: boolean | undefined; +} diff --git a/packages/resolver/src/redis/classification.ts b/packages/resolver/src/redis/classification.ts index dae18a3..d826764 100644 --- a/packages/resolver/src/redis/classification.ts +++ b/packages/resolver/src/redis/classification.ts @@ -41,6 +41,8 @@ export const setResolvedResponse = ( const indexKey = buildIndexKey(userId); const now = yield* Effect.succeed(Date.now()); + // NOTE: zcard + zrange is not atomic, so concurrent requests may over-evict by 1. + // This is acceptable — the cache is opportunistic and entries have their own TTL. const currentSize = yield* redis.use((client) => client.zcard(indexKey)); if (currentSize >= MAX_ENTRIES_PER_USER) { diff --git a/packages/resolver/src/redis/model_cost.ts b/packages/resolver/src/redis/model_cost.ts index f79ce11..7f46510 100644 --- a/packages/resolver/src/redis/model_cost.ts +++ b/packages/resolver/src/redis/model_cost.ts @@ -18,7 +18,7 @@ export const getCostForModel = (canonicalProviderModelName: string) => const costStr = yield* redis.use((client) => client.get(REDIS_PREFIX.modelToCost + canonicalProviderModelName), ); - if (!costStr) return []; + if (!costStr) return null; return yield* Schema.decodeUnknown(costSchemaParser)(costStr); }); @@ -40,8 +40,8 @@ export const bulkSetProviderModelCost = ( entries: Record, ) => Effect.gen(function* () { - const setterEffects = Object.entries(entries).map(([canonicalProvdierModelName, cost]) => - setCostForModel(canonicalProvdierModelName, cost), + const setterEffects = Object.entries(entries).map(([canonicalProviderModelName, cost]) => + setCostForModel(canonicalProviderModelName, cost), ); yield* Effect.all(setterEffects, { concurrency: 5 }); }); diff --git a/packages/resolver/src/resolver/classify.ts b/packages/resolver/src/resolver/classify.ts new file mode 100644 index 0000000..9fd3a2a --- /dev/null +++ b/packages/resolver/src/resolver/classify.ts @@ -0,0 +1,71 @@ +import { bedrock } from "@ai-sdk/amazon-bedrock"; +import { Output, generateText } from "ai"; +import { Effect } from "effect"; +import { z } from "zod"; + +import { resolverLog } from "../log"; +import { CATEGORIES, DataFetchError, ORDERS } from "../types"; +import { SYSTEM_PROMPT_CAT, SYSTEM_PROMPT_POL } from "./prompts"; + +export const RETRY_POLICY = { times: 5 }; +const LLM_MODEL = "moonshotai.kimi-k2.5"; + +const classifyWithLLM = ( + prompt: string, + systemPrompt: string, + schema: z.ZodType, + fieldName: string, + operationName: string, +) => { + const l = resolverLog(operationName); + + return Effect.tryPromise({ + try: () => + generateText({ + model: bedrock(LLM_MODEL), + system: systemPrompt, + output: Output.object({ schema }), + messages: [{ role: "user", content: [{ type: "text", text: prompt }] }], + }).then((res) => { + const value = (res.output as Record | null)?.[fieldName]; + if (value === undefined || value === null) { + throw new Error(`LLM output missing field "${fieldName}"`); + } + return value; + }), + catch: (error) => + new DataFetchError({ + reason: "APICallFailed", + message: `Failed to classify ${operationName}`, + cause: error, + }), + }).pipe( + Effect.tapError((err) => + l.error(`LLM ${operationName} classification failed`, { + llmModel: LLM_MODEL, + cause: err.cause instanceof Error ? err.cause.message : String(err.cause), + }), + ), + Effect.tap((result) => + l.debug(`LLM ${operationName} classified`, { llmModel: LLM_MODEL, result }), + ), + ); +}; + +export const classifyCategory = (prompt: string) => + classifyWithLLM( + prompt, + SYSTEM_PROMPT_CAT, + z.object({ category: z.enum(CATEGORIES) }), + "category", + "classifyCategory", + ); + +export const classifyPolicy = (prompt: string) => + classifyWithLLM( + prompt, + SYSTEM_PROMPT_POL, + z.object({ policy: z.enum(ORDERS) }), + "policy", + "classifyPolicy", + ); diff --git a/packages/resolver/src/resolver/extract_text.ts b/packages/resolver/src/resolver/extract_text.ts new file mode 100644 index 0000000..8d46fb5 --- /dev/null +++ b/packages/resolver/src/resolver/extract_text.ts @@ -0,0 +1,49 @@ +import type { CreateResponseBody } from "common"; + +export const extractTextFromInput = ( + input: CreateResponseBody["input"], + roles: Array<"user" | "system" | "developer">, +): string | null => { + if (roles.includes("user") && typeof input === "string") { + return input; + } + + if (Array.isArray(input)) { + const textContent = input + .filter((item) => roles.includes(item.role as "user" | "system" | "developer")) + .map((item) => item.content.text) + .join(" "); + + return textContent || null; + } + + return null; +}; + +/** + * Extracts the appropriate text for auto-classification based on analysisTarget. + * + * - "per_system_prompt": prioritizes system/developer prompt and instructions. + * Returns null if none found (caller should use fallback model). + * - "per_prompt" (default): prioritizes user prompt, then instructions, then system prompt. + */ +export const extractAnalysisText = ( + options: CreateResponseBody, + analysisTarget: string, +): { text: string; source: string } | null => { + if (analysisTarget === "per_system_prompt") { + const systemPrompt = extractTextFromInput(options.input, ["system", "developer"]); + if (systemPrompt) return { text: systemPrompt, source: "per_system_prompt" }; + + if (typeof options.instructions === "string" && options.instructions.length > 0) { + return { text: options.instructions, source: "per_system_prompt" }; + } + + return null; + } + + const userPrompt = extractTextFromInput(options.input, ["user"]); + if (userPrompt) return { text: userPrompt, source: "per_prompt" }; + + return null; +}; diff --git a/packages/resolver/src/resolver/index.ts b/packages/resolver/src/resolver/index.ts index 8f22db8..ac67ec5 100644 --- a/packages/resolver/src/resolver/index.ts +++ b/packages/resolver/src/resolver/index.ts @@ -2,6 +2,7 @@ import type { CreateResponseBody } from "common"; import { Effect, pipe } from "effect"; +import { resolverLog } from "../log"; import { parseImpl } from "../parser"; import { parseIntentImpl } from "../parser/parse_intent"; import { ResolveError } from "../types"; @@ -16,6 +17,8 @@ export const resolveImpl = ( analysisTarget: string, ) => Effect.gen(function* () { + const l = resolverLog("resolve"); + if (typeof options.model !== "string") { return yield* new ResolveError({ reason: "InvalidModelType", @@ -23,20 +26,32 @@ export const resolveImpl = ( }); } - if (options.model.startsWith("auto")) { - return yield* pipe( + yield* l.info("Resolve request received", { + model: options.model, + providerCount: userProviders.length, + analysisTarget: analysisTarget ?? "per_prompt", + }); + + const result = yield* (() => { + if (options.model.startsWith("auto")) { + return pipe( + options.model, + parseIntentImpl, + Effect.flatMap(resolveAuto(options as CreateResponseBody, userId, userProviders, analysisTarget)), + ); + } + + return pipe( options.model, - parseIntentImpl, - Effect.flatMap(resolveAuto(options, userId, userProviders, analysisTarget)), + parseImpl, + Effect.flatMap((parsed) => { + if (parsed._tag === "IntentPair") return resolveIntentPair(parsed, userProviders); + return resolveProviderModelPair(parsed); + }), ); - } + })(); + + yield* l.info("Resolve completed", { pairs_length: result.length }); - return yield* pipe( - options.model, - parseImpl, - Effect.flatMap((parsed) => { - if (parsed._tag === "IntentPair") return resolveIntentPair(parsed, userProviders); - return resolveProviderModelPair(parsed); - }), - ); + return result; }); diff --git a/packages/resolver/src/resolver/resolve_auto.ts b/packages/resolver/src/resolver/resolve_auto.ts index c219445..3292ebc 100644 --- a/packages/resolver/src/resolver/resolve_auto.ts +++ b/packages/resolver/src/resolver/resolve_auto.ts @@ -1,159 +1,46 @@ import type { CreateResponseBody } from "common"; -import { bedrock } from "@ai-sdk/amazon-bedrock"; -import { Output, generateText } from "ai"; import { Effect } from "effect"; -import { z } from "zod"; +import { resolverLog } from "../log"; import { getResolvedResponse, setResolvedResponse } from "../redis"; -import { CATEGORIES, DataFetchError, IntentPair, ORDERS } from "../types"; -import { ResolveError } from "../types"; -import { SYSTEM_PROMPT_CAT, SYSTEM_PROMPT_POL } from "./prompts"; +import { IntentPair, ResolveError } from "../types"; +import { classifyCategory, classifyPolicy, RETRY_POLICY } from "./classify"; +import { extractAnalysisText } from "./extract_text"; import { resolveIntentPair } from "./resolve_intent"; -const RETRY_POLICY = { times: 5 }; -const LLM_MODEL = "moonshotai.kimi-k2.5"; - -const classifyWithLLM = ( - prompt: string, - systemPrompt: string, - schema: z.ZodType, - fieldName: string, - operationName: string, -) => - Effect.tryPromise({ - try: () => - generateText({ - model: bedrock(LLM_MODEL), - system: systemPrompt, - output: Output.object({ schema }), - messages: [{ role: "user", content: [{ type: "text", text: prompt }] }], - }).then((res) => (res.output as Record)[fieldName]!), - catch: (error) => - new DataFetchError({ - reason: "APICallFailed", - message: `Failed to classify ${operationName}`, - cause: error, - }), - }).pipe( - Effect.tapError((err) => - Effect.logError(`LLM ${operationName} classification failed`).pipe( - Effect.annotateLogs({ - service: "Resolver", - operation: operationName, - llmModel: LLM_MODEL, - cause: err.cause instanceof Error ? err.cause.message : String(err.cause), - }), - ), - ), - Effect.tap((result) => - Effect.logDebug(`LLM ${operationName} classified`).pipe( - Effect.annotateLogs({ - service: "Resolver", - operation: operationName, - llmModel: LLM_MODEL, - result, - }), - ), - ), - ); - -const getCategory = (prompt: string) => - classifyWithLLM( - prompt, - SYSTEM_PROMPT_CAT, - z.object({ category: z.enum(CATEGORIES) }), - "category", - "getCategory", - ); - -const getPolicy = (prompt: string) => - classifyWithLLM( - prompt, - SYSTEM_PROMPT_POL, - z.object({ policy: z.enum(ORDERS) }), - "policy", - "getPolicy", - ); - -const extractTextFromInput = ( - input: CreateResponseBody["input"], - roles: Array<"user" | "system" | "developer">, -): string | null => { - if (roles.includes("user") && typeof input === "string") { - return input; - } - - if (Array.isArray(input)) { - const textContent = input - .filter((item) => roles.includes(item.role as "user" | "system" | "developer")) - .map((item) => item.content.text) - .join(" "); - - return textContent || null; - } - - return null; -}; - const resolveWith = ( - prompt: { - text: string; - source: string; - }, + prompt: { text: string; source: string }, pair: IntentPair, userId: string, userProviders: string[], ) => Effect.gen(function* () { - yield* Effect.logInfo("Auto-classifying with LLM").pipe( - Effect.annotateLogs({ - service: "Resolver", - operation: "resolveWith", - promptLength: prompt.text.length, - intentPolicy: pair.intentPolicy, - retryPolicy: RETRY_POLICY.times, - }), - ); + const l = resolverLog("resolveWith"); + + yield* l.info("Auto-classifying with LLM", { + promptLength: prompt.text.length, + intentPolicy: pair.intentPolicy, + }); if (prompt.source === "per_system_prompt") { const cached = yield* getResolvedResponse(userId, prompt.text); - if (cached) { - yield* Effect.logInfo("Prompt cache hit").pipe( - Effect.annotateLogs({ - service: "Resolver", - operation: "resolveWith", - message: "Serving cached data.", - }), - ); + yield* l.info("Prompt cache hit"); return cached; } } - const category = yield* Effect.retry(getCategory(prompt.text), RETRY_POLICY); - - yield* Effect.logInfo("Category classified").pipe( - Effect.annotateLogs({ - service: "Resolver", - operation: "resolveWith", - category, - }), - ); + const category = yield* Effect.retry(classifyCategory(prompt.text), RETRY_POLICY); + yield* l.info("Category classified", { category }); const policy = pair.intentPolicy === "auto" - ? yield* Effect.retry(getPolicy(prompt.text), RETRY_POLICY) + ? yield* Effect.retry(classifyPolicy(prompt.text), RETRY_POLICY) : pair.intentPolicy; if (pair.intentPolicy === "auto") { - yield* Effect.logInfo("Policy classified").pipe( - Effect.annotateLogs({ - service: "Resolver", - operation: "resolveWith", - policy, - }), - ); + yield* l.info("Policy classified", { policy }); } const intentPair = new IntentPair({ @@ -166,95 +53,45 @@ const resolveWith = ( if (prompt.source === "per_system_prompt") { yield* setResolvedResponse(userId, prompt.text, withCategory); - - yield* Effect.logInfo("Prompt cached.").pipe( - Effect.annotateLogs({ - service: "Resolver", - operation: "resolveWith", - message: "Cache event.", - }), - ); + yield* l.info("Prompt cached"); } return withCategory; }); -/** - * Extracts the appropriate text for auto-classification based on analysisTarget. - * - * - "per_system_prompt": prioritizes system/developer prompt and instructions. - * Returns null if none found (caller should use fallback model). - * - "per_prompt" (default): prioritizes user prompt, then instructions, then system prompt. - */ -const extractAnalysisText = ( - options: CreateResponseBody, - analysisTarget: string, -): { text: string; source: string } | null => { - if (analysisTarget === "per_system_prompt") { - const systemPrompt = extractTextFromInput(options.input, ["system", "developer"]); - if (systemPrompt) return { text: systemPrompt, source: "per_system_prompt" }; - - if (typeof options.instructions === "string" && options.instructions.length > 0) { - return { text: options.instructions, source: "per_system_prompt" }; - } - - return null; - } - - const userPrompt = extractTextFromInput(options.input, ["user"]); - if (userPrompt) return { text: userPrompt, source: "per_prompt" }; - - return null; -}; - export const resolveAuto = (options: CreateResponseBody, userId: string, userProviders: string[], analysisTarget: string) => (pair: IntentPair) => Effect.gen(function* () { + const l = resolverLog("resolveAuto"); const extracted = extractAnalysisText(options, analysisTarget); if (!extracted) { if (analysisTarget === "per_system_prompt") { - yield* Effect.logInfo( - "No system prompt found for per_system_prompt analysis, signaling fallback", - ).pipe( - Effect.annotateLogs({ - service: "Resolver", - operation: "resolveAuto", - analysisTarget, - }), - ); - + yield* l.info("No system prompt found for per_system_prompt analysis, signaling fallback", { + analysisTarget, + }); return yield* new ResolveError({ reason: "UnsupportedInputType", message: "No system prompt found. Using fallback model.", }); } - yield* Effect.logError("No extractable text for auto-classification").pipe( - Effect.annotateLogs({ - service: "Resolver", - operation: "resolveAuto", - inputType: typeof options.input, - analysisTarget: analysisTarget, - }), - ); - + yield* l.error("No extractable text for auto-classification", { + inputType: typeof options.input, + analysisTarget, + }); return yield* new ResolveError({ reason: "UnsupportedInputType", message: "We currently only support texts.", }); } - yield* Effect.logDebug(`Using ${extracted.source} for auto-classification`).pipe( - Effect.annotateLogs({ - service: "Resolver", - operation: "resolveAuto", - source: extracted.source, - promptLength: extracted.text.length, - analysisTarget: analysisTarget ?? "per_prompt", - }), - ); + yield* l.debug(`Using ${extracted.source} for auto-classification`, { + source: extracted.source, + promptLength: extracted.text.length, + analysisTarget, + }); return yield* resolveWith(extracted, pair, userId, userProviders); }); diff --git a/packages/resolver/src/resolver/resolve_intent.ts b/packages/resolver/src/resolver/resolve_intent.ts index 0ac00f4..9c6998c 100644 --- a/packages/resolver/src/resolver/resolve_intent.ts +++ b/packages/resolver/src/resolver/resolve_intent.ts @@ -2,21 +2,20 @@ import { Array as Arr, Effect } from "effect"; import type { IntentPair } from "../types"; +import { resolverLog } from "../log"; import { getPotentialModelsForIntentPair } from "../data_manager"; import * as Redis from "../redis/index"; import { NoProviderAvailableError } from "../types"; export const resolveIntentPair = (pair: IntentPair, userProviders: string[]) => Effect.gen(function* () { - yield* Effect.logDebug("Resolving intent pair").pipe( - Effect.annotateLogs({ - service: "Resolver", - operation: "resolveIntentPair", - intent: pair.intent, - intentPolicy: pair.intentPolicy, - providerCount: userProviders.length, - }), - ); + const l = resolverLog("resolveIntentPair"); + + yield* l.debug("Resolving intent pair", { + intent: pair.intent, + intentPolicy: pair.intentPolicy, + providerCount: userProviders.length, + }); const potentialModels = yield* getPotentialModelsForIntentPair(pair); const userProviderSet = new Set(userProviders); @@ -36,14 +35,10 @@ export const resolveIntentPair = (pair: IntentPair, userProviders: string[]) => ); if (pairs.length > 0) { - yield* Effect.logInfo("Intent resolved to provider/model").pipe( - Effect.annotateLogs({ - service: "Resolver", - operation: "resolveIntentPair", - intent: pair.intent, - intentPolicy: pair.intentPolicy, - }), - ); + yield* l.info("Intent resolved to provider/model", { + intent: pair.intent, + intentPolicy: pair.intentPolicy, + }); return pairs; } @@ -52,17 +47,13 @@ export const resolveIntentPair = (pair: IntentPair, userProviders: string[]) => (results) => Arr.dedupe(results.flat().map(({ provider }) => provider)), ); - yield* Effect.logWarning("No matching provider found for intent").pipe( - Effect.annotateLogs({ - service: "Resolver", - operation: "resolveIntentPair", - intent: pair.intent, - intentPolicy: pair.intentPolicy, - userProviders: userProviders.join(","), - availableProviders: availableProviders.join(","), - candidateCount: potentialModels.length, - }), - ); + yield* l.warn("No matching provider found for intent", { + intent: pair.intent, + intentPolicy: pair.intentPolicy, + userProviders: userProviders.join(","), + availableProviders: availableProviders.join(","), + candidateCount: potentialModels.length, + }); return yield* new NoProviderAvailableError({ reason: "NoProviderConfigured", diff --git a/packages/resolver/src/resolver/resolve_provider_model.ts b/packages/resolver/src/resolver/resolve_provider_model.ts index 948e792..dc9dae7 100644 --- a/packages/resolver/src/resolver/resolve_provider_model.ts +++ b/packages/resolver/src/resolver/resolve_provider_model.ts @@ -2,14 +2,4 @@ import { Effect } from "effect"; import type { ProviderModelPair } from "../types"; export const resolveProviderModelPair = (pair: ProviderModelPair) => - Effect.succeed([{ model: pair.model, provider: pair.provider, category: null as string | null }]).pipe( - Effect.tap((resolved) => - Effect.logDebug("Provider/model passthrough resolved").pipe( - Effect.annotateLogs({ - service: "Resolver", - operation: "resolveProviderModel", - resolved, - }), - ), - ), - ); + Effect.succeed([{ model: pair.model, provider: pair.provider, category: null as string | null }]); diff --git a/packages/services/package.json b/packages/services/package.json new file mode 100644 index 0000000..c767c9a --- /dev/null +++ b/packages/services/package.json @@ -0,0 +1,17 @@ +{ + "name": "@enfinyte/services", + "private": true, + "type": "module", + "main": "src/index.ts", + "dependencies": { + "resolver": "workspace:*", + "vault": "workspace:*", + "ledger": "workspace:*" + }, + "devDependencies": { + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^5" + } +} diff --git a/packages/services/src/index.ts b/packages/services/src/index.ts new file mode 100644 index 0000000..21bb96c --- /dev/null +++ b/packages/services/src/index.ts @@ -0,0 +1,25 @@ +// Resolver +export { ResolverService, ResolverServiceLive, ResolverLoggerLive } from "resolver"; +export type { + DataFetchError, + IntentParseError, + NoProviderAvailableError, + ProviderModelParseError, + ResolveError, +} from "resolver"; + +// Vault +export { VaultService, VaultServiceLive, VaultLoggerLive } from "vault"; +export type { VaultError, VaultPathError } from "vault"; + +// Ledger +export { LedgerService, LedgerError, fromEnv as LedgerServiceLive } from "ledger"; +export type { + DashboardOverview, + ErrorRateMetric, + LedgerInterval, + ModelCost, + ProviderModelLatency, + TimeSeriesBucket, + Transaction, +} from "ledger"; diff --git a/packages/services/tsconfig.json b/packages/services/tsconfig.json new file mode 100644 index 0000000..4082f16 --- /dev/null +++ b/packages/services/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../../tsconfig.json" +}