From 3e611ad97df227c8618aea6df00d86f3fbf17526 Mon Sep 17 00:00:00 2001 From: Ben Davis Date: Fri, 19 Dec 2025 23:30:15 -0800 Subject: [PATCH 1/4] moved comment & sponsor parsing over to baml --- bun.lock | 21 ++++ packages/channel-sync/.gitignore | 2 + packages/channel-sync/package.json | 5 +- packages/channel-sync/src/ai/index.ts | 69 +------------ .../channel-sync/src/baml_src/clients.baml | 16 +++ .../src/baml_src/comment-sentiment.baml | 53 ++++++++++ .../channel-sync/src/baml_src/generators.baml | 18 ++++ .../src/baml_src/video-sponsor.baml | 99 +++++++++++++++++++ 8 files changed, 218 insertions(+), 65 deletions(-) create mode 100644 packages/channel-sync/src/baml_src/clients.baml create mode 100644 packages/channel-sync/src/baml_src/comment-sentiment.baml create mode 100644 packages/channel-sync/src/baml_src/generators.baml create mode 100644 packages/channel-sync/src/baml_src/video-sponsor.baml diff --git a/bun.lock b/bun.lock index d62a859..d3fda2f 100644 --- a/bun.lock +++ b/bun.lock @@ -79,6 +79,7 @@ "name": "@r8y/channel-sync", "dependencies": { "@ai-sdk/groq": "^2.0.33", + "@boundaryml/baml": "^0.215.0", "@doist/todoist-api-typescript": "^6.2.1", "@openrouter/ai-sdk-provider": "^1.5.4", "ai": "^5.0.115", @@ -127,6 +128,24 @@ "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.19", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA=="], + "@boundaryml/baml": ["@boundaryml/baml@0.215.0", "", { "dependencies": { "@scarf/scarf": "^1.3.0" }, "optionalDependencies": { "@boundaryml/baml-darwin-arm64": "0.215.0", "@boundaryml/baml-darwin-x64": "0.215.0", "@boundaryml/baml-linux-arm64-gnu": "0.215.0", "@boundaryml/baml-linux-arm64-musl": "0.215.0", "@boundaryml/baml-linux-x64-gnu": "0.215.0", "@boundaryml/baml-linux-x64-musl": "0.215.0", "@boundaryml/baml-win32-arm64-msvc": "0.215.0", "@boundaryml/baml-win32-x64-msvc": "0.215.0" }, "bin": { "baml-cli": "cli.js", "baml": "cli.js" } }, "sha512-LDi43sTFDWQFPywERVD11l09fqsDQvVoujNyIYd5hNZFA2jI/UbrgtH1+KZ2S7SCvLfJlHmKrgM8f990/fgbAA=="], + + "@boundaryml/baml-darwin-arm64": ["@boundaryml/baml-darwin-arm64@0.215.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Y3BRTnAEoHIBURd3gIBroJgy1/2P2DZRgCEpMkaIb8oNl3l122C/pYu4j6jft4s0ehQRRqfS1hAO7m08zxmC8w=="], + + "@boundaryml/baml-darwin-x64": ["@boundaryml/baml-darwin-x64@0.215.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-FYnAfVEMuL0XpFIy2R5NKteAwsg3lAQTjHwJUPS3Go5lvi0x90g+X4UxpL0IFndKz9anjFsZezH926+NF+NOlg=="], + + "@boundaryml/baml-linux-arm64-gnu": ["@boundaryml/baml-linux-arm64-gnu@0.215.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-5RjllnLbk+O1mWLQf2TW60Z8zmLpkhwLWn4/h4ce0egV9pGKuHCIvV9NGl0hXmhMNV1JzJ4/KU38t2YAjpGNVQ=="], + + "@boundaryml/baml-linux-arm64-musl": ["@boundaryml/baml-linux-arm64-musl@0.215.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-ucEkzDWb9SnmbFcs5TYP4R8UirtT0p/fPKOaGEuqbnFQAhUZvSo4WyJaTz4N5RdRdPq4//CJp9jVQp5y9qJ1DQ=="], + + "@boundaryml/baml-linux-x64-gnu": ["@boundaryml/baml-linux-x64-gnu@0.215.0", "", { "os": "linux", "cpu": "x64" }, "sha512-WvJ9UqXcgpnstpPjkDekkb066QTqWIJwbW37VFoIo8hCH147Lbf0GjDib8DRcWl+o3dxCTnPszxuMYtksKFngQ=="], + + "@boundaryml/baml-linux-x64-musl": ["@boundaryml/baml-linux-x64-musl@0.215.0", "", { "os": "linux", "cpu": "x64" }, "sha512-IYkW1WU/LDSnFcFdrSrd+GDZQJYXwGhKIdRnApH1dtt0CkpBd1CF2b7WfeE74Sz8L+I+YJ7U0AZlS3Xe3XvoLQ=="], + + "@boundaryml/baml-win32-arm64-msvc": ["@boundaryml/baml-win32-arm64-msvc@0.215.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-gLE3H0qbqxtRrkd6hHbS3GKUQhQIHUfp1htxCzQTXVqChhtd+gWnVW0rf80EgL54Y0Ds1Z/r0UAi25Y029ibWA=="], + + "@boundaryml/baml-win32-x64-msvc": ["@boundaryml/baml-win32-x64-msvc@0.215.0", "", { "os": "win32", "cpu": "x64" }, "sha512-L83apHU5S1DWcyq1zLpzba1ho68iYrgiRQr1oLXrhj1NEW9hQXboK4d+f8EJu+qnlKzk6knmBjf8j6KHXUxEZQ=="], + "@discordjs/builders": ["@discordjs/builders@1.13.1", "", { "dependencies": { "@discordjs/formatters": "^0.6.2", "@discordjs/util": "^1.2.0", "@sapphire/shapeshift": "^4.0.0", "discord-api-types": "^0.38.33", "fast-deep-equal": "^3.1.3", "ts-mixer": "^6.0.4", "tslib": "^2.6.3" } }, "sha512-cOU0UDHc3lp/5nKByDxkmRiNZBpdp0kx55aarbiAfakfKJHlxv/yFW1zmIqCAmwH5CRlrH9iMFKJMpvW4DPB+w=="], "@discordjs/collection": ["@discordjs/collection@1.5.3", "", {}, "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ=="], @@ -365,6 +384,8 @@ "@sapphire/snowflake": ["@sapphire/snowflake@3.5.3", "", {}, "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ=="], + "@scarf/scarf": ["@scarf/scarf@1.4.0", "", {}, "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ=="], + "@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="], "@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.8", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA=="], diff --git a/packages/channel-sync/.gitignore b/packages/channel-sync/.gitignore index a14702c..e12bc4a 100644 --- a/packages/channel-sync/.gitignore +++ b/packages/channel-sync/.gitignore @@ -10,6 +10,8 @@ dist coverage *.lcov +baml_client/ + # logs logs _.log diff --git a/packages/channel-sync/package.json b/packages/channel-sync/package.json index 14aea6d..7ef41dc 100644 --- a/packages/channel-sync/package.json +++ b/packages/channel-sync/package.json @@ -6,7 +6,9 @@ "scripts": { "check": "tsc --noEmit", "format": "prettier --write .", - "lint": "prettier --check ." + "lint": "prettier --check .", + "gen:bml": "baml-cli generate --from ./src/baml_src", + "postinstall": "bun run gen:bml" }, "devDependencies": { "@effect/cli": "^0.72.1", @@ -20,6 +22,7 @@ }, "dependencies": { "@ai-sdk/groq": "^2.0.33", + "@boundaryml/baml": "^0.215.0", "@doist/todoist-api-typescript": "^6.2.1", "@openrouter/ai-sdk-provider": "^1.5.4", "ai": "^5.0.115", diff --git a/packages/channel-sync/src/ai/index.ts b/packages/channel-sync/src/ai/index.ts index f93e188..f09cc0c 100644 --- a/packages/channel-sync/src/ai/index.ts +++ b/packages/channel-sync/src/ai/index.ts @@ -1,8 +1,6 @@ -import { generateObject } from 'ai'; -import z from 'zod'; import { Effect, Schedule } from 'effect'; import { TaggedError } from 'effect/Data'; -import { createOpenRouter } from '@openrouter/ai-sdk-provider'; +import { b } from '../baml_client'; const retrySchedule = Schedule.intersect(Schedule.spaced('1 minute'), Schedule.recurs(3)); @@ -21,84 +19,27 @@ const aiService = Effect.gen(function* () { return yield* Effect.die('OPENROUTER_API_KEY is not set'); } - const openrouter = createOpenRouter({ - apiKey: openrouterApiKey, - headers: { - 'HTTP-Referer': 'https://r8y.app', - 'X-Title': 'r8y' - } - }); - - const hmm = openrouter('openai/gpt-oss-120b', { - extraBody: { - provider: { - only: ['groq', 'cerebras'] - } - } - }); - return { classifyComment: (data: { comment: string; videoSponsor: string | null }) => Effect.gen(function* () { - const classificationOutputSchema = z.object({ - isEditingMistake: z.boolean(), - isSponsorMention: z.boolean(), - isQuestion: z.boolean(), - isPositiveComment: z.boolean() - }); - const result = yield* Effect.tryPromise({ - try: () => - generateObject({ - model: hmm, - prompt: `Your job is to classify this youtube video's comment. You need to return a boolean true/false for each of the following criteria: - - - The comment is flagging an editing mistake - - The comment is flagging a packaging mistake (typo in title/description/thumbnail, missing link in description, etc.) - - The comment mentions the video's sponsor (or the channel's sponsors in general) - - The comment is a question - - The comment is a positive comment (the general sentiment of the comment is positive, this should be true unless the comment is a direct complaint/critique, if it's neutral it should be true) - - The video sponsor is: - ${data.videoSponsor || 'No sponsor'} - - The comment is: - ${data.comment} - `, - schema: classificationOutputSchema - }), + try: () => b.ClassifyComment(data.comment, data.videoSponsor), catch: (err) => { return new AiError('Failed to classify comment', { cause: err }); } }).pipe(Effect.retry(retrySchedule)); - return result.object; + return result; }), getSponsor: (data: { sponsorPrompt: string; videoDescription: string }) => Effect.gen(function* () { - const sponsorOutputSchema = z.object({ - sponsorName: z.string(), - sponsorKey: z.string() - }); - const result = yield* Effect.tryPromise({ - try: () => - generateObject({ - model: hmm, - prompt: `Your job is to parse this youtube video's description to find the sponsor, and a key to identify the sponsor in the db. The following will tell you how to get each of those for this channel: - - ${data.sponsorPrompt} - - The video description is: - ${data.videoDescription} - `, - schema: sponsorOutputSchema - }), + try: () => b.GetSponsor(data.sponsorPrompt, data.videoDescription.toLocaleLowerCase()), catch: (err) => new AiError('Failed to get sponsor', { cause: err }) }).pipe(Effect.retry(retrySchedule)); - return result.object; + return result; }) }; }); diff --git a/packages/channel-sync/src/baml_src/clients.baml b/packages/channel-sync/src/baml_src/clients.baml new file mode 100644 index 0000000..c7a0731 --- /dev/null +++ b/packages/channel-sync/src/baml_src/clients.baml @@ -0,0 +1,16 @@ +client OpenrouterGptOssClient { + provider "openai-generic" + options { + base_url "https://openrouter.ai/api/v1" + api_key env.OPENROUTER_API_KEY + model "openai/gpt-oss-120b" + provider { + only ["groq", "cerebras"] + } + headers { + "HTTP-Referer" "https://r8y.app" + "X-Title" "r8y" + } + } +} + diff --git a/packages/channel-sync/src/baml_src/comment-sentiment.baml b/packages/channel-sync/src/baml_src/comment-sentiment.baml new file mode 100644 index 0000000..5b7c6cd --- /dev/null +++ b/packages/channel-sync/src/baml_src/comment-sentiment.baml @@ -0,0 +1,53 @@ +class CommentSentiment { + isEditingMistake bool + isSponsorMention bool + isQuestion bool + isPositiveComment bool +} + +function ClassifyComment(comment: string, videoSponsor: string | null) -> CommentSentiment { + client OpenrouterGptOssClient + prompt #" + Read this comment decide if it mentions an editing mistake (something wrong with the video's audio, video, title, thumbnail, or description), the video's sponsor, and/or is a question. + Also decide if the comment is positive or negative. + + The video's sponsor is {{ videoSponsor }} + + The comment is: + {{ comment }} + + {{ ctx.output_format }} + "# +} + +test g2i_mention { + functions [ClassifyComment] + args { + comment "Can confirm, used g2i and have made a hire. BUT... they did not go from interview to first pull request in one week. It was 2 pull requests. :)" + videoSponsor "g2i" + } +} + +test positive_comment { + functions [ClassifyComment] + args { + comment "AI therapist is so funny 😂 'You passed the test!' lmao" + videoSponsor null + } +} + +test question_comment { + functions [ClassifyComment] + args { + comment "what's `renanme`" + videoSponsor "greptile" + } +} + +test negative_question_comment { + functions [ClassifyComment] + args { + comment "Why are you paying for API usage? Just use Max for 100 bucks! Aren't you an investor who can afford $ 100 a month?" + videoSponsor "rork" + } +} \ No newline at end of file diff --git a/packages/channel-sync/src/baml_src/generators.baml b/packages/channel-sync/src/baml_src/generators.baml new file mode 100644 index 0000000..43c8447 --- /dev/null +++ b/packages/channel-sync/src/baml_src/generators.baml @@ -0,0 +1,18 @@ +// This helps use auto generate libraries you can use in the language of +// your choice. You can have multiple generators if you use multiple languages. +// Just ensure that the output_dir is different for each generator. +generator target { + // Valid values: "python/pydantic", "typescript", "ruby/sorbet", "rest/openapi" + output_type "typescript" + + // Where the generated code will be saved (relative to baml_src/) + output_dir "../" + + // The version of the BAML package you have installed (e.g. same version as your baml-py or @boundaryml/baml). + // The BAML VSCode extension version should also match this version. + version "0.215.0" + + // Valid values: "sync", "async" + // This controls what `b.FunctionName()` will be (sync or async). + default_client_mode async +} diff --git a/packages/channel-sync/src/baml_src/video-sponsor.baml b/packages/channel-sync/src/baml_src/video-sponsor.baml new file mode 100644 index 0000000..c894f1c --- /dev/null +++ b/packages/channel-sync/src/baml_src/video-sponsor.baml @@ -0,0 +1,99 @@ +class SponsorInfo { + sponsorName string + sponsorKey string +} + +function GetSponsor(sponsorPrompt: string, videoDescription: string) -> SponsorInfo { + client OpenrouterGptOssClient + prompt #" + Parse this youtube video's description to find the video's sponsor. You are looking for the sponsor's name and a key to identify the sponsor in the db. Both the key and name should be lowercase. + + The following will tell you how to get each of those for this channel: + {{ sponsorPrompt }} + + The video description is: + {{ videoDescription }} + + {{ ctx.output_format }} + "# +} + +test no_sponsor { + functions [GetSponsor] + args { + sponsorPrompt "The sponsor key for this channel is `https://soydev.link/${SPONSOR_NAME}`. There are often multiple soydev links in the description. The one for the sponsor will come after something similar to 'Thank you ${SPONSOR_NAME} for sponsoring!'. If it doesn't mention that the sponsor name is a sponsor, then there is no sponsor and you should set the sponsor name to 'no sponsor' and the sponsor key to 'https://t3.gg'" + videoDescription #" + There's a lot wrong with MCP, thankfully Anthropic seems to be finally doing something about it... + + no sponsor today, go checkout t3 chat: https://soydev.link/chat + +SOURCE +https://www.anthropic.com/engineering... + +Want to sponsor a video? Learn more here: https://soydev.link/sponsor-me + +Check out my Twitch, Twitter, Discord more at https://t3.gg + +S/O Ph4se0n3 for the awesome edit 🙏 + "# + } +} + +test sevalla_sponsor { + functions [GetSponsor] + args { + sponsorPrompt "The sponsor key for this channel is `https://soydev.link/${SPONSOR_NAME}`. There are often multiple soydev links in the description. The one for the sponsor will come after something similar to 'Thank you ${SPONSOR_NAME} for sponsoring!'. If it doesn't mention that the sponsor name is a sponsor, then there is no sponsor and you should set the sponsor name to 'no sponsor' and the sponsor key to 'https://t3.gg'" + videoDescription #" + There's a lot wrong with MCP, thankfully Anthropic seems to be finally doing something about it... + +Thank you Sevalla for sponsoring! Check them out at: https://soydev.link/sevalla + +SOURCE +https://www.anthropic.com/engineering... + +Want to sponsor a video? Learn more here: https://soydev.link/sponsor-me + +Check out my Twitch, Twitter, Discord more at https://t3.gg + +S/O Ph4se0n3 for the awesome edit 🙏 + "# + } +} + +test convex_sponsor { + functions [GetSponsor] + args { + sponsorPrompt "The sponsor key for this channel is `https://soydev.link/${SPONSOR_NAME}`. There are often multiple soydev links in the description. The one for the sponsor will come after something similar to 'Thank you ${SPONSOR_NAME} for sponsoring!'. If it doesn't mention that the sponsor name is a sponsor, then there is no sponsor and you should set the sponsor name to 'no sponsor' and the sponsor key to 'https://t3.gg'" + videoDescription #" + My journey with Arc was...weird. If you followed me here, I'm sorry. I hope this helps. + +Thank you Convex for sponsoring! Check them out at https://soydev.link/convex + +PLEASE PAY ZEN BROWSER, THEY NEED TO WIN +https://zen-browser.app/donate/ + +Check out my Twitch, Twitter, Discord more at https://t3.gg + +S/O Ph4se0n3 for the awesome edit 🙏 + "# + } +} + +test g2i_sponsor { + functions [GetSponsor] + args { + sponsorPrompt "The sponsor key for this channel is `https://soydev.link/${SPONSOR_NAME}`. There are often multiple soydev links in the description. The one for the sponsor will come after something similar to 'Thank you ${SPONSOR_NAME} for sponsoring!'. If it doesn't mention that the sponsor name is a sponsor, then there is no sponsor and you should set the sponsor name to 'no sponsor' and the sponsor key to 'https://t3.gg'" + videoDescription #" + Claude Code just got a ton of new features, all of which are really really cool. I just wish they worked better... + +Thank you G2i for sponsoring! Check them out at: https://soydev.link/g2i + +Want to sponsor a video? Learn more here: https://soydev.link/sponsor-me + +Check out my Twitch, Twitter, Discord more at https://t3.gg + +S/O Ph4se0n3 for the awesome edit 🙏 + "# + } +} + From 4b6af6cb98bbb4e4efa23edd1cf14512b62d6567 Mon Sep 17 00:00:00 2001 From: Ben Davis Date: Fri, 19 Dec 2025 23:47:39 -0800 Subject: [PATCH 2/4] asserts --- packages/channel-sync/src/baml_src/comment-sentiment.baml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/channel-sync/src/baml_src/comment-sentiment.baml b/packages/channel-sync/src/baml_src/comment-sentiment.baml index 5b7c6cd..9d7f50b 100644 --- a/packages/channel-sync/src/baml_src/comment-sentiment.baml +++ b/packages/channel-sync/src/baml_src/comment-sentiment.baml @@ -9,7 +9,7 @@ function ClassifyComment(comment: string, videoSponsor: string | null) -> Commen client OpenrouterGptOssClient prompt #" Read this comment decide if it mentions an editing mistake (something wrong with the video's audio, video, title, thumbnail, or description), the video's sponsor, and/or is a question. - Also decide if the comment is positive or negative. + Also decide if the comment is generally positive or negative. The video's sponsor is {{ videoSponsor }} @@ -26,6 +26,11 @@ test g2i_mention { comment "Can confirm, used g2i and have made a hire. BUT... they did not go from interview to first pull request in one week. It was 2 pull requests. :)" videoSponsor "g2i" } + @@assert({{ this.isSponsorMention == true}}) + @@assert({{ this.isQuestion == false}}) + @@assert({{ this.isEditingMistake == false}}) + // need to optimize the prompt for this... + @@assert({{ this.isPositiveComment == true}}) } test positive_comment { From c04ed1c29ad0e9c52bd8ea6637ecf718b6910a3d Mon Sep 17 00:00:00 2001 From: Ben Davis Date: Fri, 19 Dec 2025 23:53:59 -0800 Subject: [PATCH 3/4] suppress the llm output in backfill --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 9fec369..24f9826 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "docker:bg": "docker build -f docker/Dockerfile.bg -t r8y-bg .", "prepare": "effect-language-service patch", "db": "bun run --cwd packages/db src/cli.ts", - "backfill": "bun run --cwd packages/channel-sync src/scripts/backfill.ts backfill" + "backfill": "BAML_LOG=warn bun run --cwd packages/channel-sync src/scripts/backfill.ts backfill" }, "devDependencies": { "@effect/language-service": "^0.62.0", From 41690259f6450eebd8f33439cbd6871b767a793f Mon Sep 17 00:00:00 2001 From: Ben Davis Date: Fri, 19 Dec 2025 23:58:13 -0800 Subject: [PATCH 4/4] retry in baml --- packages/channel-sync/src/ai/index.ts | 2 +- packages/channel-sync/src/baml_src/clients.baml | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/channel-sync/src/ai/index.ts b/packages/channel-sync/src/ai/index.ts index f09cc0c..adbc08c 100644 --- a/packages/channel-sync/src/ai/index.ts +++ b/packages/channel-sync/src/ai/index.ts @@ -2,7 +2,7 @@ import { Effect, Schedule } from 'effect'; import { TaggedError } from 'effect/Data'; import { b } from '../baml_client'; -const retrySchedule = Schedule.intersect(Schedule.spaced('1 minute'), Schedule.recurs(3)); +const retrySchedule = Schedule.intersect(Schedule.spaced('1 minute'), Schedule.recurs(2)); class AiError extends TaggedError('AiError') { constructor(message: string, options?: { cause?: unknown }) { diff --git a/packages/channel-sync/src/baml_src/clients.baml b/packages/channel-sync/src/baml_src/clients.baml index c7a0731..9b5d987 100644 --- a/packages/channel-sync/src/baml_src/clients.baml +++ b/packages/channel-sync/src/baml_src/clients.baml @@ -1,5 +1,10 @@ +retry_policy TryThreeTimes { + max_retries 3 +} + client OpenrouterGptOssClient { provider "openai-generic" + retry_policy TryThreeTimes options { base_url "https://openrouter.ai/api/v1" api_key env.OPENROUTER_API_KEY