Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 142 additions & 0 deletions .github/workflows/gateway-ai.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
name: AI Gateway Generation

# Generates a custom gateway plugin with a free-tier LLM.
# 1. Register the gateway in packages/collectors/manifest.json (url + key + optional sample).
# 2. Add the gateway's API key as a repository secret (it must be listed in
# packages/collectors/src/core/gateway-secrets.ts before collection can use it).
# 3. Run this workflow with the gateway id, or open a PR that touches manifest.json
# to validate the manifest without generating anything.
on:
workflow_dispatch:
inputs:
gateway_id:
description: 'Gateway id listed in packages/collectors/manifest.json'
required: true
type: string
mode:
description: 'bootstrap (new plugin) or heal (fix existing plugin)'
required: false
default: bootstrap
type: choice
options:
- bootstrap
- heal
pull_request:
types: [opened, synchronize]
paths:
- 'packages/collectors/manifest.json'

permissions:
contents: write
pull-requests: write

jobs:
generate:
if: github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Install pnpm
uses: pnpm/action-setup@v3
with:
version: 9

- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Build schema & registry
run: pnpm --filter @basemodel/schema --filter @basemodel/registry build

- name: Generate gateway plugin via LLM
run: |
FLAGS=""
if [ "${{ github.event.inputs.mode }}" == "heal" ]; then FLAGS="--heal"; fi
pnpm --filter @basemodel/collectors run gen-gateway "${{ github.event.inputs.gateway_id }}" $FLAGS
env:
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
GOOGLE_AI_API_KEY: ${{ secrets.GOOGLE_AI_API_KEY }}
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
TOGETHER_API_KEY: ${{ secrets.TOGETHER_API_KEY }}
DEEPINFRA_API_KEY: ${{ secrets.DEEPINFRA_API_KEY }}
FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }}
CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }}
HYPERBOLIC_API_KEY: ${{ secrets.HYPERBOLIC_API_KEY }}
REQUESTY_API_KEY: ${{ secrets.REQUESTY_API_KEY }}
PORTKEY_API_KEY: ${{ secrets.PORTKEY_API_KEY }}
MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}
COHERE_API_KEY: ${{ secrets.COHERE_API_KEY }}
LITELLM_BASE_URL: ${{ secrets.LITELLM_BASE_URL }}
LITELLM_API_KEY: ${{ secrets.LITELLM_API_KEY }}

- name: Validate generated plugin
run: |
pnpm --filter @basemodel/collectors run typecheck
pnpm --filter @basemodel/collectors run test

- name: Commit generated plugin
run: |
BRANCH="ai/gateway/${{ github.event.inputs.gateway_id }}"
git config user.name "BaseModel Bot"
git config user.email "bot@basemodel"
git checkout -b "$BRANCH"
git add packages/collectors/src/gateways packages/collectors/manifest.json
git commit -m "feat(gateways): AI-generated plugin for ${{ github.event.inputs.gateway_id }}" || echo "No changes to commit"
git push -u origin "$BRANCH"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

- name: Open PR
run: |
BRANCH="ai/gateway/${{ github.event.inputs.gateway_id }}"
gh pr create \
--base main \
--head "$BRANCH" \
--title "feat(gateways): AI-generated plugin for ${{ github.event.inputs.gateway_id }}" \
--body "AI-generated gateway plugin for \`${{ github.event.inputs.gateway_id }}\` (mode: ${{ github.event.inputs.mode }}).

Reviewed before merge, per docs/08_Gateway_Plugin_Security.md:
- Confirm the plugin only uses secrets registered in \`packages/collectors/src/core/gateway-secrets.ts\`.
- Add the gateway's API key as a repository secret so nightly collection can use it.
- Run \`pnpm --filter @basemodel/collectors run verify <path>\` to smoke-test with a live key." \
|| echo "PR may already exist for this gateway."
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

validate-manifest:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Install pnpm
uses: pnpm/action-setup@v3
with:
version: 9

- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Build schema & registry
run: pnpm --filter @basemodel/schema --filter @basemodel/registry build

- name: Validate manifest
run: |
pnpm --filter @basemodel/collectors exec tsx -e "(async () => { const m = await import('./src/gateway-gen/manifest.js'); await m.loadManifest(); console.log('manifest OK'); })()"
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ Useful package-level commands:

- `pnpm --filter @basemodel/collectors run collect`
- `pnpm --filter @basemodel/collectors run verify packages/collectors/src/gateways/openai.ts`
- `pnpm --filter @basemodel/collectors run gen-gateway <id>` - generate a custom gateway plugin for a gateway registered in `packages/collectors/manifest.json`

## License

Expand Down
21 changes: 21 additions & 0 deletions docs/04_Pipeline.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,26 @@ Collectors fetch data from official APIs, documentation, or other approved sourc

Current collector support includes both OpenAI-compatible gateway plugins and custom gateway plugins.

#### AI Gateway Generation

Providers whose APIs do not match the OpenAI-compatible shape can register a
gateway in `packages/collectors/manifest.json` (base URL, auth, and optionally a
static sample response). A free-tier LLM then generates a custom gateway plugin:

1. `pnpm --filter @basemodel/collectors run gen-gateway <id>` probes the
endpoint (or uses the sample), captures a redacted fixture, and summarizes
the response shape.
2. The LLM writes `packages/collectors/src/gateways/<id>.ts` implementing the
`CustomGateway` contract.
3. Output is structurally validated before use (importable module, correct id,
`collect()` present, no forbidden patterns such as `any` or hardcoded keys)
and retried up to 3 times.
4. `--heal` regenerates a plugin from its existing fixture when a sample changes
or a mapping breaks.

Generated plugins are proposed through a pull request and must be reviewed
before merge. See `docs/08_Gateway_Plugin_Security.md`.

### Validation

Validation checks required fields, identifier formats, schema compliance, URL validity, and timestamp formats.
Expand Down Expand Up @@ -98,3 +118,4 @@ The pipeline is automated through GitHub Actions:
- `publish.yml` regenerates datasets on push to `main`.
- `deploy-pages.yml` publishes the generated static files.
- `verify-gateway.yml` checks gateway plugin changes.
- `gateway-ai.yml` generates gateway plugins with an LLM and opens a review PR; it also validates the manifest on PRs that touch it.
21 changes: 21 additions & 0 deletions docs/08_Gateway_Plugin_Security.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,27 @@ the central secret registry.
An unregistered gateway receives no secrets. A plugin file must not be able to
escalate its own privileges by declaring a new secret name.

## AI-Generated Gateways

`gateway-gen` writes custom gateway plugins with a free-tier LLM (Gemini
flash-lite by default, OpenRouter `:free` fallback) for providers registered in
`packages/collectors/manifest.json`. The generator stays deterministic about
data: it probes the endpoint (or reads a static sample) and feeds the LLM a
redacted fixture plus a shape summary, never live credentials.

AI output is treated as untrusted code. Before a generated plugin is accepted:

- It is structurally validated before writing (importable module, correct
gateway id, `collect()` present) and rejected when it uses forbidden patterns
(`any`, hardcoded API keys, `process.exit`) or refers to secrets not listed in
`gateway-secrets.ts`.
- The workflow opens a pull request; human review is required before merge.
- Reviewers re-check secret usage and add the provider's API key as a repository
secret; only then does nightly collection use the plugin.

New custom gateways therefore pass through the same review gate as hand-written
plugins.

## Remaining Risk

Process isolation reduces exposure, but it is not an operating-system sandbox.
Expand Down
79 changes: 79 additions & 0 deletions packages/collectors/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
{
"version": 1,
"gateways": [
{
"id": "cohere",
"baseUrl": "https://api.cohere.com",
"endpoint": "/v1/models",
"auth": {
"type": "bearer",
"secret": "COHERE_API_KEY"
},
"secrets": ["COHERE_API_KEY"],
"sample": {
"models": [
{
"name": "command-r-plus",
"endpoints": ["chat", "embed", "generate", "summarize", "classify", "rerank"],
"finetuned": false,
"context_length": 128000,
"tokenizer_url": "https://docs.cohere.com/docs/tokenizer",
"default_endpoints": ["chat"],
"features": ["supports_rag", "experimental_chat"]
},
{
"name": "command-r",
"endpoints": ["chat", "embed", "generate", "summarize", "classify", "rerank"],
"finetuned": false,
"context_length": 128000,
"default_endpoints": ["chat"],
"features": ["supports_rag"]
},
{
"name": "command-light",
"endpoints": ["chat", "generate", "summarize"],
"finetuned": false,
"context_length": 4096,
"default_endpoints": ["chat"],
"features": null
},
{
"name": "embed-english-v3.0",
"endpoints": ["embed"],
"finetuned": false,
"context_length": 512,
"default_endpoints": ["embed"],
"features": ["search", "classification"]
},
{
"name": "rerank-english-v3.0",
"endpoints": ["rerank"],
"finetuned": false,
"context_length": 4096,
"default_endpoints": ["rerank"],
"features": null
},
{
"name": "embed-english-light-v3.0",
"endpoints": ["embed"],
"finetuned": false,
"context_length": null,
"tokenizer_url": null,
"default_endpoints": ["embed"],
"features": null
},
{
"name": "command-nightly-finetuned",
"endpoints": [],
"finetuned": true,
"context_length": 0,
"default_endpoints": [],
"features": null
}
],
"next_page_token": "cD0yCg=="
},
"notes": "Example gateway for the AI generator: non-OpenAI-compatible response shape ({ models: [...] }). A static sample is provided so the generator can run without a live API key."
}
]
}
1 change: 1 addition & 0 deletions packages/collectors/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"build": "tsup src/index.ts --format esm --dts --clean",
"collect": "tsx src/run.ts",
"enrich": "tsx src/enrich/run.ts",
"gen-gateway": "tsx src/gateway-gen/index.ts",
"verify": "tsx src/core/verify.ts",
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests",
Expand Down
69 changes: 69 additions & 0 deletions packages/collectors/src/__tests__/generated-gateways.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { existsSync } from 'node:fs';
import { readFile } from 'node:fs/promises';
import { ModelSchema } from '@basemodel/schema';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { getFixturePath, getGatewayPluginPath, loadManifest } from '../gateway-gen/manifest.js';
import { fixtureExists } from '../gateway-gen/probe.js';

afterEach(() => {
vi.unstubAllGlobals();
});

describe('AI-generated gateway plugins', () => {
it('map their fixture sample into valid Models without a live API', async () => {
const manifest = await loadManifest();
let exercised = 0;

for (const gateway of manifest.gateways) {
const pluginPath = getGatewayPluginPath(gateway.id);
const fixturePath = getFixturePath(gateway.id);
if (!existsSync(pluginPath) || !fixtureExists(gateway.id)) continue;

const fixture = JSON.parse(await readFile(fixturePath, 'utf-8')) as unknown;
const plugin = ((await import(pluginPath)) as { default?: unknown }).default as {
id?: string;
collect: (secrets: Record<string, string | undefined>) => Promise<{
provider_id: string;
models: Array<Record<string, unknown>>;
errors: string[];
}>;
};
expect(plugin.id).toBe(gateway.id);

let fetchCalls = 0;
const fetchMock = vi.fn(async () => {
fetchCalls += 1;
if (fetchCalls > 2) {
throw new Error('mock: no more pages (pagination guard)');
}
return new Response(JSON.stringify(fixture), {
status: 200,
headers: { 'content-type': 'application/json' },
});
});
vi.stubGlobal('fetch', fetchMock);

const secrets: Record<string, string | undefined> = { ...process.env };
for (const name of [
...gateway.secrets,
...(gateway.auth?.secret ? [gateway.auth.secret] : []),
]) {
if (!secrets[name]) secrets[name] = 'test-key';
}

const result = await plugin.collect(secrets);
expect(fetchMock).toHaveBeenCalled();
expect(result.provider_id).toBe(gateway.id);
for (const model of result.models) {
const parsed = ModelSchema.safeParse(model);
expect(
parsed.success,
`${model.model_id ?? '(no model_id)'} invalid: ${parsed.error?.message}`,
).toBe(true);
}
exercised += 1;
}

expect(exercised).toBeGreaterThan(0);
});
});
1 change: 1 addition & 0 deletions packages/collectors/src/core/gateway-secrets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export const GATEWAY_SECRET_KEYS = {
anthropic: ['ANTHROPIC_API_KEY'],
cerebras: ['CEREBRAS_API_KEY'],
cloudflare: ['CLOUDFLARE_ACCOUNT_ID', 'CLOUDFLARE_API_TOKEN'],
cohere: ['COHERE_API_KEY'],
deepinfra: ['DEEPINFRA_API_KEY'],
fireworks: ['FIREWORKS_API_KEY'],
google: ['GOOGLE_AI_API_KEY'],
Expand Down
Loading
Loading