Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
4 changes: 3 additions & 1 deletion containers/api-proxy/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ COPY package*.json ./
RUN npm ci --omit=dev

# Copy application files
COPY server.js logging.js metrics.js rate-limiter.js token-tracker.js model-resolver.js anthropic-cache.js anthropic-transforms.js ./
COPY server.js logging.js metrics.js rate-limiter.js token-tracker.js \
model-resolver.js proxy-utils.js anthropic-cache.js anthropic-transforms.js ./
COPY providers/ ./providers/

# Create non-root user
RUN addgroup -S apiproxy && adduser -S apiproxy -G apiproxy
Expand Down
223 changes: 223 additions & 0 deletions containers/api-proxy/providers/ADDING-A-PROVIDER.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
# Adding a New LLM Provider to the AWF API Proxy

This guide explains how to wire a new LLM provider into the AWF API proxy in three steps:

1. Create an adapter file in this directory (`providers/<name>.js`)
2. Register it in `providers/index.js`
3. Update the Dockerfile COPY list

The core proxy engine (`server.js`) is completely agnostic of provider details — it only calls the methods defined on the `ProviderAdapter` interface. You never need to touch the core to add a new provider.

---

## Step 1 — Create the adapter file

Create `providers/<name>.js`. The adapter is a plain JS object (no class syntax required) returned by a factory function:

```js
'use strict';

const { normalizeApiTarget, normalizeBasePath } = require('../proxy-utils');

function createMyProviderAdapter(env, deps = {}) {
// Read credentials and config from env at construction time
const apiKey = (env.MY_PROVIDER_API_KEY || '').trim() || undefined;
const target = normalizeApiTarget(env.MY_PROVIDER_API_TARGET) || 'api.myprovider.com';
const basePath = normalizeBasePath(env.MY_PROVIDER_API_BASE_PATH);

const bodyTransform = deps.bodyTransform || null; // model-alias rewriting etc.

return {
// ── Identity ─────────────────────────────────────────────────────────────
name: 'my-provider', // unique lowercase slug
port: 10005, // next available port (update Dockerfile EXPOSE too)

isManagementPort: false, // true only for port 10000 (OpenAI)
alwaysBind: false, // set true to start a 503-stub when not configured
get participatesInValidation() { return this.isEnabled(); },

// ── Credentials ──────────────────────────────────────────────────────────
isEnabled() { return !!apiKey; },
getTargetHost() { return target; },
getBasePath() { return basePath; },

// ── Per-request auth headers ──────────────────────────────────────────────
// `req` is the incoming http.IncomingMessage — inspect it for request-specific logic.
getAuthHeaders(req) {
return { 'Authorization': `Bearer ${apiKey}` };
},

// ── Optional: URL transform ───────────────────────────────────────────────
// Return the (possibly modified) URL string, or omit this method entirely.
transformRequestUrl(url) { return url; },

// ── Optional: body transform ──────────────────────────────────────────────
// Return a function (body: Buffer) => Buffer|null, or null for no transform.
getBodyTransform() { return bodyTransform; },

// ── Startup: credential validation ───────────────────────────────────────
// Return a probe config, a skip config, or null if validation is not applicable.
getValidationProbe() {
if (!apiKey) return null;
if (target !== 'api.myprovider.com') {
return { skip: true, reason: `Custom target ${target}; validation skipped` };
}
return {
url: `https://${target}/v1/models`,
opts: { method: 'GET', headers: { 'Authorization': `Bearer ${apiKey}` } },
};
},

// ── Startup: model listing ────────────────────────────────────────────────
// Return null to opt out of model fetching.
getModelsFetchConfig() {
if (!apiKey) return null;
return {
url: `https://${target}/v1/models`,
opts: { method: 'GET', headers: { 'Authorization': `Bearer ${apiKey}` } },
cacheKey: 'my-provider', // key in cachedModels; must match name
};
},

// ── /reflect endpoint metadata ────────────────────────────────────────────
getReflectionInfo() {
return {
provider: 'my-provider',
port: 10005,
base_url: 'http://api-proxy:10005',
configured: !!apiKey,
models_cache_key: 'my-provider', // null when models are not fetched
models_url: 'http://api-proxy:10005/v1/models',
};
},
};
}

module.exports = { createMyProviderAdapter };
```

### Provider adapter reference

| Method / property | Required? | Description |
|---|---|---|
| `name` | ✅ | Unique lowercase slug (matches cache key and log labels) |
| `port` | ✅ | Port to listen on; must be unique across all adapters |
| `isManagementPort` | ✅ | `true` only for the one port that serves `/health`, `/metrics`, `/reflect` |
| `alwaysBind` | ✅ | `true` to start a stub server even when `isEnabled()` returns false |
| `participatesInValidation` | ✅ | `true` when this adapter should count in the startup latch |
| `isEnabled()` | ✅ | Returns `true` when credentials are present |
| `getTargetHost(req?)` | ✅ | Returns the upstream hostname |
| `getBasePath(req?)` | ✅ | Returns the URL path prefix (empty string for none) |
| `getAuthHeaders(req)` | ✅ | Returns headers to inject (auth, version, integration ID, …) |
| `transformRequestUrl(url)` | ➖ optional | Mutate the request URL before forwarding (e.g. strip query params) |
| `getBodyTransform()` | ✅ | Returns `(Buffer) => Buffer\|null` or `null` |
| `getValidationProbe()` | ✅ | Returns probe config, `{ skip, reason }`, or `null` |
| `getModelsFetchConfig()` | ✅ | Returns fetch config or `null` |
| `getReflectionInfo()` | ✅ | Returns endpoint metadata for `/reflect` and `models.json` |
| `getUnconfiguredResponse()` | ➖ optional | Response for proxy requests when `alwaysBind=true` & not enabled |
| `getUnconfiguredHealthResponse()` | ➖ optional | `/health` response when not enabled (defaults to 503) |

---

## Step 2 — Register in `providers/index.js`

```js
// 1. Import your factory
const { createMyProviderAdapter } = require('./my-provider');

// 2. Construct the adapter alongside the others in createAllAdapters():
function createAllAdapters(env, deps = {}) {
const openai = createOpenAIAdapter(env, { bodyTransform: deps.openaiBodyTransform });
const anthropic = createAnthropicAdapter(env, { bodyTransform: deps.anthropicBodyTransform });
const copilot = createCopilotAdapter(env, { bodyTransform: deps.copilotBodyTransform });
const gemini = createGeminiAdapter(env, { bodyTransform: deps.geminiBodyTransform });
const myProvider = createMyProviderAdapter(env, { bodyTransform: deps.myProviderBodyTransform }); // ← add here

// OpenCode routes to the first enabled candidate in priority order.
// Add myProvider to this list if you want OpenCode to route through it.
const opencode = createOpenCodeAdapter(env, { candidateAdapters: [openai, anthropic, copilot] });

return [openai, anthropic, copilot, gemini, opencode, myProvider]; // ← include in return
}

// 3. Export it alongside the others
module.exports = {
createAllAdapters,
// ...existing exports...
createMyProviderAdapter,
};
```

If your provider needs model-alias rewriting, also add a corresponding
`myProviderBodyTransform` in server.js (mirroring how the existing transforms
are built and passed into `createAllAdapters`).

### Optional: add your provider to OpenCode's routing

OpenCode (port 10004) automatically routes to the first enabled adapter in its
`candidateAdapters` list. If you want OpenCode to fall back to your provider,
add it to that list in the desired priority position — **no changes to
`opencode.js` are needed**:

```js
Comment on lines +158 to +162
// In createAllAdapters(), update the opencode line:
const opencode = createOpenCodeAdapter(env, {
candidateAdapters: [openai, anthropic, copilot, myProvider], // ← add at desired priority position
});
```

All providers remain independently reachable on their own ports regardless of
whether they appear in the OpenCode candidate list.

---

## Step 3 — Update the Dockerfile

Add the new adapter file to the explicit `COPY` list in `containers/api-proxy/Dockerfile`:

```dockerfile
COPY server.js logging.js metrics.js rate-limiter.js token-tracker.js \
model-resolver.js proxy-utils.js anthropic-cache.js anthropic-transforms.js \
providers/ ./
```

Also update the `EXPOSE` directive to include the new port:

```dockerfile
EXPOSE 10000 10001 10002 10003 10004 10005
```

---

## Checklist

- [ ] `providers/<name>.js` created and exports `create<Name>Adapter`
- [ ] Adapter registered in `providers/index.js` (`createAllAdapters` + exports)
- [ ] `Dockerfile` updated: `providers/` in COPY list, port in EXPOSE
- [ ] Add provider env vars to `src/docker-manager.ts` if they need forwarding from the host
- [ ] Add domain to `docs/allowed-domains.md` or equivalent if the upstream is new
- [ ] Write adapter unit tests in `providers/<name>.test.js`
- [ ] (Optional) Add adapter to OpenCode's `candidateAdapters` list in `providers/index.js` if OpenCode should route through it

---

## Testing your adapter in isolation

Because each adapter is a plain object, you can unit-test it without starting any HTTP servers:

```js
const { createMyProviderAdapter } = require('./my-provider');

describe('MyProvider adapter', () => {
it('returns correct auth headers', () => {
const adapter = createMyProviderAdapter({ MY_PROVIDER_API_KEY: 'test-key' });
const fakeReq = { headers: {}, method: 'POST', url: '/v1/chat' };
expect(adapter.getAuthHeaders(fakeReq)).toEqual({ Authorization: 'Bearer test-key' });
});

it('reports not configured when key is absent', () => {
const adapter = createMyProviderAdapter({});
expect(adapter.isEnabled()).toBe(false);
});
});
```
Loading
Loading