feat(M3-003): implement OpenAI provider adapter for Codex gpt-5.4
Some checks failed
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/pr/ci Pipeline failed

Adds OpenAIAdapter implementing IProviderAdapter, replacing the legacy
registerOpenAIProvider() inline registration in ProviderService.

- Creates apps/gateway/src/agent/adapters/openai.adapter.ts with:
  - register(): initialises OpenAI client, registers codex-gpt-5-4 with
    Pi ModelRegistry using openai-completions API; skips gracefully when
    OPENAI_API_KEY is absent
  - listModels(): returns Codex gpt-5.4 ModelInfo (128k context, tools+vision)
  - healthCheck(): lightweight GET /v1/models to verify API key validity
  - createCompletion(): streaming completions via openai SDK
    chat.completions.create({ stream: true }); maps chunks to CompletionEvent
- Installs openai SDK (^6.32.0) as a dependency in apps/gateway
- Registers OpenAIAdapter in ProviderService alongside OllamaAdapter
- Removes legacy registerOpenAIProvider() private method from ProviderService

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-21 16:30:56 -05:00
parent cfdd2b679c
commit 6f104d116b
5 changed files with 229 additions and 27 deletions

View File

@@ -17,13 +17,13 @@
"@mariozechner/pi-coding-agent": "~0.57.1",
"@modelcontextprotocol/sdk": "^1.27.1",
"@mosaic/auth": "workspace:^",
"@mosaic/queue": "workspace:^",
"@mosaic/brain": "workspace:^",
"@mosaic/coord": "workspace:^",
"@mosaic/db": "workspace:^",
"@mosaic/discord-plugin": "workspace:^",
"@mosaic/log": "workspace:^",
"@mosaic/memory": "workspace:^",
"@mosaic/queue": "workspace:^",
"@mosaic/telegram-plugin": "workspace:^",
"@mosaic/types": "workspace:^",
"@nestjs/common": "^11.0.0",
@@ -46,6 +46,7 @@
"dotenv": "^17.3.1",
"fastify": "^5.0.0",
"node-cron": "^4.2.1",
"openai": "^6.32.0",
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.0",
"socket.io": "^4.8.0",

View File

@@ -1 +1,2 @@
export { OllamaAdapter } from './ollama.adapter.js';
export { OpenAIAdapter } from './openai.adapter.js';

View File

@@ -0,0 +1,201 @@
import { Logger } from '@nestjs/common';
import OpenAI from 'openai';
import type { ModelRegistry } from '@mariozechner/pi-coding-agent';
import type {
CompletionEvent,
CompletionParams,
IProviderAdapter,
ModelInfo,
ProviderHealth,
} from '@mosaic/types';
/**
* OpenAI provider adapter.
*
* Registers OpenAI models (including Codex gpt-5.4) with the Pi ModelRegistry.
* Configuration is driven by environment variables:
* OPENAI_API_KEY — OpenAI API key (required; adapter skips registration when absent)
*/
export class OpenAIAdapter implements IProviderAdapter {
readonly name = 'openai';
private readonly logger = new Logger(OpenAIAdapter.name);
private registeredModels: ModelInfo[] = [];
private client: OpenAI | null = null;
/** Model ID used for Codex gpt-5.4 in the Pi registry. */
static readonly CODEX_MODEL_ID = 'codex-gpt-5-4';
constructor(private readonly registry: ModelRegistry) {}
async register(): Promise<void> {
const apiKey = process.env['OPENAI_API_KEY'];
if (!apiKey) {
this.logger.debug('Skipping OpenAI provider registration: OPENAI_API_KEY not set');
return;
}
this.client = new OpenAI({ apiKey });
const codexModel = {
id: OpenAIAdapter.CODEX_MODEL_ID,
name: 'Codex gpt-5.4',
/** OpenAI-compatible completions API */
api: 'openai-completions' as never,
reasoning: false,
input: ['text', 'image'] as ('text' | 'image')[],
cost: { input: 0.003, output: 0.012, cacheRead: 0.0015, cacheWrite: 0 },
contextWindow: 128_000,
maxTokens: 16_384,
};
this.registry.registerProvider('openai', {
apiKey,
baseUrl: 'https://api.openai.com/v1',
models: [codexModel],
});
this.registeredModels = [
{
id: OpenAIAdapter.CODEX_MODEL_ID,
provider: 'openai',
name: 'Codex gpt-5.4',
reasoning: false,
contextWindow: 128_000,
maxTokens: 16_384,
inputTypes: ['text', 'image'] as ('text' | 'image')[],
cost: { input: 0.003, output: 0.012, cacheRead: 0.0015, cacheWrite: 0 },
},
];
this.logger.log(`OpenAI provider registered with model: ${OpenAIAdapter.CODEX_MODEL_ID}`);
}
listModels(): ModelInfo[] {
return this.registeredModels;
}
async healthCheck(): Promise<ProviderHealth> {
const apiKey = process.env['OPENAI_API_KEY'];
if (!apiKey) {
return {
status: 'down',
lastChecked: new Date().toISOString(),
error: 'OPENAI_API_KEY not configured',
};
}
const start = Date.now();
try {
// Lightweight call — list models to verify key validity
const res = await fetch('https://api.openai.com/v1/models', {
method: 'GET',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
signal: AbortSignal.timeout(5000),
});
const latencyMs = Date.now() - start;
if (!res.ok) {
return {
status: 'degraded',
latencyMs,
lastChecked: new Date().toISOString(),
error: `HTTP ${res.status}`,
};
}
return { status: 'healthy', latencyMs, lastChecked: new Date().toISOString() };
} catch (err) {
const latencyMs = Date.now() - start;
const error = err instanceof Error ? err.message : String(err);
return { status: 'down', latencyMs, lastChecked: new Date().toISOString(), error };
}
}
/**
* Stream a completion from OpenAI using the chat completions API.
*
* Maps OpenAI streaming chunks to the Mosaic CompletionEvent format.
*/
async *createCompletion(params: CompletionParams): AsyncIterable<CompletionEvent> {
if (!this.client) {
throw new Error(
'OpenAIAdapter: client not initialized. ' +
'Ensure OPENAI_API_KEY is set and register() was called.',
);
}
const stream = await this.client.chat.completions.create({
model: params.model,
messages: params.messages.map((m) => ({
role: m.role,
content: m.content,
})),
...(params.temperature !== undefined && { temperature: params.temperature }),
...(params.maxTokens !== undefined && { max_tokens: params.maxTokens }),
...(params.tools &&
params.tools.length > 0 && {
tools: params.tools.map((t) => ({
type: 'function' as const,
function: {
name: t.name,
description: t.description,
parameters: t.parameters,
},
})),
}),
stream: true,
stream_options: { include_usage: true },
});
let inputTokens = 0;
let outputTokens = 0;
for await (const chunk of stream) {
const choice = chunk.choices[0];
// Accumulate usage when present (final chunk with stream_options.include_usage)
if (chunk.usage) {
inputTokens = chunk.usage.prompt_tokens;
outputTokens = chunk.usage.completion_tokens;
}
if (!choice) continue;
const delta = choice.delta;
// Text content delta
if (delta.content) {
yield { type: 'text_delta', content: delta.content };
}
// Tool call delta — emit when arguments are complete
if (delta.tool_calls) {
for (const toolCallDelta of delta.tool_calls) {
if (toolCallDelta.function?.name && toolCallDelta.function.arguments !== undefined) {
yield {
type: 'tool_call',
name: toolCallDelta.function.name,
arguments: toolCallDelta.function.arguments,
};
}
}
}
// Stream finished
if (choice.finish_reason === 'stop' || choice.finish_reason === 'tool_calls') {
yield {
type: 'done',
usage: { inputTokens, outputTokens },
};
return;
}
}
// Fallback done event when stream ends without explicit finish_reason
yield { type: 'done', usage: { inputTokens, outputTokens } };
}
}

View File

@@ -8,7 +8,7 @@ import type {
ProviderHealth,
ProviderInfo,
} from '@mosaic/types';
import { OllamaAdapter } from './adapters/index.js';
import { OllamaAdapter, OpenAIAdapter } from './adapters/index.js';
import type { TestConnectionResultDto } from './provider.dto.js';
/** DI injection token for the provider adapter array. */
@@ -31,15 +31,14 @@ export class ProviderService implements OnModuleInit {
this.registry = new ModelRegistry(authStorage);
// Build the default set of adapters that rely on the registry
this.adapters = [new OllamaAdapter(this.registry)];
this.adapters = [new OllamaAdapter(this.registry), new OpenAIAdapter(this.registry)];
// Run all adapter registrations first (Ollama, and any future adapters)
await this.registerAll();
// Register API-key providers directly (Anthropic, OpenAI, Z.ai, custom)
// These do not yet have dedicated adapter classes (M3-002 through M3-005).
// Register API-key providers directly (Anthropic, Z.ai, custom)
// OpenAI now has a dedicated adapter class (M3-003).
this.registerAnthropicProvider();
this.registerOpenAIProvider();
this.registerZaiProvider();
this.registerCustomProviders();
@@ -234,7 +233,7 @@ export class ProviderService implements OnModuleInit {
// ---------------------------------------------------------------------------
// Private helpers — direct registry registration for providers without adapters yet
// (Anthropic, OpenAI, Z.ai will move to adapters in M3-002 through M3-005)
// (Anthropic, Z.ai will move to adapters in M3-002, M3-005)
// ---------------------------------------------------------------------------
private registerAnthropicProvider(): void {
@@ -257,26 +256,6 @@ export class ProviderService implements OnModuleInit {
this.logger.log('Anthropic provider registered with 3 models');
}
private registerOpenAIProvider(): void {
const apiKey = process.env['OPENAI_API_KEY'];
if (!apiKey) {
this.logger.debug('Skipping OpenAI provider registration: OPENAI_API_KEY not set');
return;
}
const models = ['gpt-4o', 'gpt-4o-mini', 'o3-mini'].map((id) =>
this.cloneBuiltInModel('openai', id),
);
this.registry.registerProvider('openai', {
apiKey,
baseUrl: 'https://api.openai.com/v1',
models,
});
this.logger.log('OpenAI provider registered with 3 models');
}
private registerZaiProvider(): void {
const apiKey = process.env['ZAI_API_KEY'];
if (!apiKey) {

20
pnpm-lock.yaml generated
View File

@@ -143,6 +143,9 @@ importers:
node-cron:
specifier: ^4.2.1
version: 4.2.1
openai:
specifier: ^6.32.0
version: 6.32.0(ws@8.19.0)(zod@4.3.6)
reflect-metadata:
specifier: ^0.2.0
version: 0.2.2
@@ -4879,6 +4882,18 @@ packages:
zod:
optional: true
openai@6.32.0:
resolution: {integrity: sha512-j3k+BjydAf8yQlcOI7WUQMQTbbF5GEIMAE2iZYCOzwwB3S2pCheaWYp+XZRNAch4jWVc52PMDGRRjutao3lLCg==}
hasBin: true
peerDependencies:
ws: ^8.18.0
zod: ^3.25 || ^4.0
peerDependenciesMeta:
ws:
optional: true
zod:
optional: true
optionator@0.9.4:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'}
@@ -10621,6 +10636,11 @@ snapshots:
ws: 8.19.0
zod: 4.3.6
openai@6.32.0(ws@8.19.0)(zod@4.3.6):
optionalDependencies:
ws: 8.19.0
zod: 4.3.6
optionator@0.9.4:
dependencies:
deep-is: 0.1.4