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
61 changes: 61 additions & 0 deletions scripts/dial-model-compat-check.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { AzureOpenAIModelProxy } from '../src/providers/plugins/azure-openai/azure-openai.models.js';
import { config } from 'dotenv';

config();

async function main() {
const baseUrl = process.env.DIAL_BASE_URL || process.env.CODEMIE_AZURE_OPENAI_BASE_URL || '';
const apiKey = process.env.DIAL_API_KEY || process.env.CODEMIE_AZURE_OPENAI_API_KEY || '';
const apiVersion = process.env.DIAL_API_VERSION || '2024-06-01';
if (!baseUrl || !apiKey) {
console.error('[dial-model-compat-check] DIAL_BASE_URL and DIAL_API_KEY required in env');
process.exit(2);
}
const proxy = new AzureOpenAIModelProxy(baseUrl, apiKey, apiVersion);
const config = { baseUrl, apiKey, azureApiVersion: apiVersion };
const models = await proxy.fetchModels(config);
console.log(`Found ${models.length} deployments`);
const results: Array<{ id: string; name: string; status: string; error?: string; latency?: number }> = [];
for (const m of models) {
const testPayload = {
model: m.id,
messages: [{ role: 'user', content: 'ping' }],
max_tokens: 16
};
const url = `${baseUrl}/openai/deployments/${encodeURIComponent(m.id)}/chat/completions?api-version=${apiVersion}`;
const headers = {
'api-key': apiKey,
'Content-Type': 'application/json'
};
const t0 = Date.now();
try {
const resp = await fetch(url, { method: 'POST', headers, body: JSON.stringify(testPayload) });
const body = await resp.text();
if (!resp.ok) {
results.push({ id: m.id, name: m.name, status: 'FAIL', error: `HTTP ${resp.status}: ${body}` });
console.error(`[${m.name}] FAIL: HTTP ${resp.status}: ${body}`);
} else {
const delta = Date.now() - t0;
results.push({ id: m.id, name: m.name, status: 'OK', latency: delta });
console.log(`[${m.name}] OK (${delta} ms)`);
}
} catch (e: any) {
results.push({ id: m.id, name: m.name, status: 'ERROR', error: e?.message || String(e) });
console.error(`[${m.name}] ERROR: ${e?.message || e}`);
}
}

// Print summary table
console.log('\n--- DIAL Model Compatibility Report ---');
results.forEach(r => {
let line = `${r.name.padEnd(26)} | ${r.status}`;
if (r.latency) line += ` (${r.latency} ms)`;
if (r.error) line += ` :: ${r.error.substring(0, 80)}`;
console.log(line);
});
}

main().catch(e => {
console.error('[dial-model-compat-check] Fatal:', e);
process.exit(1);
});
1 change: 1 addition & 0 deletions src/agents/core/BaseAgentAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -506,6 +506,7 @@ export abstract class BaseAgentAdapter implements AgentAdapter {
// Display ASCII logo with configuration
console.log(
renderProfileInfo({
title: 'Profile',
profile: profileName,
provider,
model,
Expand Down
13 changes: 7 additions & 6 deletions src/agents/core/__tests__/BaseAgentAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,13 @@ vi.mock('../../../providers/core/registry.js', () => {
};
return {
ProviderRegistry: {
registerProvider: vi.fn((t: any) => t),
registerSetupSteps: vi.fn(),
registerHealthCheck: vi.fn(),
registerModelProxy: vi.fn(),
getProvider: vi.fn((name: string) => providers[name]),
getProviderNames: vi.fn(() => Object.keys(providers)),
registerProvider: vi.fn((t: any) => t),
registerSetupSteps: vi.fn(),
registerHealthCheck: vi.fn(),
registerModelProxy: vi.fn(),
registerProviderSetup: vi.fn((t: any) => t),
getProvider: vi.fn((name: string) => providers[name]),
getProviderNames: vi.fn(() => Object.keys(providers)),
},
};
});
Expand Down
32 changes: 32 additions & 0 deletions src/agents/plugins/__tests__/codemie-code-reasoning.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,16 @@ vi.mock('../reasoning-sanitizer/index.js', () => ({
cleanupReasoningSanitizerPlugin: mockCleanupReasoningSanitizer,
}));

// Mock azure-dial-sanitizer
const { mockGetAzureDialSanitizerPluginUrl, mockCleanupAzureDialSanitizer } = vi.hoisted(() => ({
mockGetAzureDialSanitizerPluginUrl: vi.fn(() => 'file:///mock/azure-dial-sanitizer.ts'),
mockCleanupAzureDialSanitizer: vi.fn(),
}));
vi.mock('../azure-dial-sanitizer/index.js', () => ({
getAzureDialSanitizerPluginUrl: mockGetAzureDialSanitizerPluginUrl,
cleanupAzureDialSanitizerPlugin: mockCleanupAzureDialSanitizer,
}));

// Mock OpenCodeSessionAdapter
const { mockDiscoverSessions } = vi.hoisted(() => ({
mockDiscoverSessions: vi.fn(),
Expand Down Expand Up @@ -107,6 +117,14 @@ vi.mock('../opencode/opencode-dynamic-models.js', () => ({
fetchDynamicModelConfigs: vi.fn(() => Promise.resolve({})),
}));

// Mock AzureOpenAIModelProxy so azure-openai provider path doesn't make real requests
vi.mock('../../../providers/plugins/azure-openai/azure-openai.models.js', () => ({
AzureOpenAIModelProxy: vi.fn().mockImplementation(() => ({
fetchDeploymentInfos: vi.fn(() => Promise.resolve([])),
fetchModels: vi.fn(() => Promise.resolve([])),
})),
}));

// Mock fs
vi.mock('fs', () => ({
existsSync: vi.fn(() => true),
Expand Down Expand Up @@ -242,6 +260,19 @@ describe('CodeMie Code Plugin — Reasoning Sanitization Integration', () => {
expect(config.plugin).toContain('file:///mock/hooks-plugin.js');
expect(config.plugin).toContain('file:///mock/reasoning-sanitizer.ts');
});

it('injects azure-dial-sanitizer plugin for azure-openai provider', async () => {
const env = createEnv({
CODEMIE_PROVIDER: 'azure-openai',
CODEMIE_API_KEY: 'azure-key',
CODEMIE_AZURE_OPENAI_BASE_URL: 'https://dial.example.com',
});
await beforeRun(env, {} as any);

const config = parseConfig(env);
expect(config.plugin).toContain('file:///mock/azure-dial-sanitizer.ts');
expect(mockGetAzureDialSanitizerPluginUrl).toHaveBeenCalled();
});
});

describe('Cleanup — onSessionEnd', () => {
Expand Down Expand Up @@ -276,6 +307,7 @@ describe('CodeMie Code Plugin — Reasoning Sanitization Integration', () => {

expect(mockCleanupHooksPlugin).toHaveBeenCalled();
expect(mockCleanupReasoningSanitizer).toHaveBeenCalled();
expect(mockCleanupAzureDialSanitizer).toHaveBeenCalled();
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/**
* Tests for AZURE_DIAL_SANITIZER_PLUGIN_SOURCE string constant.
*
* Pure string validation — no mocks needed.
*
* @group unit
*/

import { describe, it, expect } from 'vitest';
import { AZURE_DIAL_SANITIZER_PLUGIN_SOURCE } from '../azure-dial-sanitizer-source.js';

describe('AZURE_DIAL_SANITIZER_PLUGIN_SOURCE', () => {
it('is a non-empty string', () => {
expect(typeof AZURE_DIAL_SANITIZER_PLUGIN_SOURCE).toBe('string');
expect(AZURE_DIAL_SANITIZER_PLUGIN_SOURCE.length).toBeGreaterThan(0);
});

it('contains OpenCode Plugin type import and default export', () => {
expect(AZURE_DIAL_SANITIZER_PLUGIN_SOURCE).toContain('import type { Plugin } from "@opencode-ai/plugin"');
expect(AZURE_DIAL_SANITIZER_PLUGIN_SOURCE).toContain('export default');
});

it('contains chat.params hook', () => {
expect(AZURE_DIAL_SANITIZER_PLUGIN_SOURCE).toContain('"chat.params"');
});

describe('provider detection', () => {
it('detects azure-dial- provider by prefix', () => {
expect(AZURE_DIAL_SANITIZER_PLUGIN_SOURCE).toContain('azure-dial-');
});

it('checks providerID', () => {
expect(AZURE_DIAL_SANITIZER_PLUGIN_SOURCE).toContain('providerID');
});

it('uses case-insensitive comparison', () => {
expect(AZURE_DIAL_SANITIZER_PLUGIN_SOURCE).toContain('.toLowerCase()');
});
});

describe('cache_control stripping', () => {
it('strips cache_control from message content', () => {
expect(AZURE_DIAL_SANITIZER_PLUGIN_SOURCE).toContain('cache_control');
expect(AZURE_DIAL_SANITIZER_PLUGIN_SOURCE).toContain('sanitizeMessage');
});

it('strips cache_control from top-level message field', () => {
expect(AZURE_DIAL_SANITIZER_PLUGIN_SOURCE).toContain('delete m["cache_control"]');
});

it('strips cache_control from content[] items inside message', () => {
expect(AZURE_DIAL_SANITIZER_PLUGIN_SOURCE).toContain('delete cleaned["cache_control"]');
});

it('handles array content (multipart messages)', () => {
expect(AZURE_DIAL_SANITIZER_PLUGIN_SOURCE).toContain('Array.isArray');
});

it('strips cache_control for ALL models including Claude', () => {
// No isClaude guard — always strip for azure-dial providers
expect(AZURE_DIAL_SANITIZER_PLUGIN_SOURCE).not.toContain('isClaude');
expect(AZURE_DIAL_SANITIZER_PLUGIN_SOURCE).not.toContain('isClaudeModel');
});

it('applies sanitizeMessage to all messages', () => {
expect(AZURE_DIAL_SANITIZER_PLUGIN_SOURCE).toContain('output.messages.map(sanitizeMessage)');
});

it('strips reasoning_content from messages and nested parts', () => {
expect(AZURE_DIAL_SANITIZER_PLUGIN_SOURCE).toContain('reasoning_content');
expect(AZURE_DIAL_SANITIZER_PLUGIN_SOURCE).toContain('delete m["reasoning_content"]');
expect(AZURE_DIAL_SANITIZER_PLUGIN_SOURCE).toContain('delete cleaned["reasoning_content"]');
});
});

describe('thinking stripping', () => {
it('strips thinking param', () => {
expect(AZURE_DIAL_SANITIZER_PLUGIN_SOURCE).toContain('"thinking"');
expect(AZURE_DIAL_SANITIZER_PLUGIN_SOURCE).toContain('sanitizeParamsContainer');
});
});

describe('reasoning param stripping', () => {
it('strips reasoningSummary', () => {
expect(AZURE_DIAL_SANITIZER_PLUGIN_SOURCE).toContain('"reasoningSummary"');
});

it('strips reasoning_summary', () => {
expect(AZURE_DIAL_SANITIZER_PLUGIN_SOURCE).toContain('"reasoning_summary"');
});

it('strips reasoning', () => {
expect(AZURE_DIAL_SANITIZER_PLUGIN_SOURCE).toContain('"reasoning"');
});

it('strips broader top-level compatibility fields', () => {
expect(AZURE_DIAL_SANITIZER_PLUGIN_SOURCE).toContain('"parallel_tool_calls"');
expect(AZURE_DIAL_SANITIZER_PLUGIN_SOURCE).toContain('"store"');
expect(AZURE_DIAL_SANITIZER_PLUGIN_SOURCE).toContain('"metadata"');
expect(AZURE_DIAL_SANITIZER_PLUGIN_SOURCE).toContain('"prediction"');
});

it('normalizes messages to allowed OpenAI fields', () => {
expect(AZURE_DIAL_SANITIZER_PLUGIN_SOURCE).toContain('ALLOWED_MESSAGE_FIELDS');
expect(AZURE_DIAL_SANITIZER_PLUGIN_SOURCE).toContain('ALLOWED_TOOL_CALL_FIELDS');
});
});
});
34 changes: 34 additions & 0 deletions src/agents/plugins/azure-dial-sanitizer/auto-retry-sanitizer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// Auto-retry request with sanitizer for Azure DIAL/Azure OpenAI endpoint

import { sanitizeAzureDialPayload } from './azure-dial-sanitizer-source.js';

/**
* Performs a request to DIAL/Azure endpoint, automatically sanitizing the payload in case of "Extra inputs are not permitted" error
* @param send Async function making the request (SDK or http fetch)
* @param payload Original request payload
* @returns Successful response/result or exception if the error is not fixable
*/
export async function requestWithSanitizerRetry(send: (payload: any) => Promise<any>, payload: any): Promise<any> {
// First attempt — original payload
try {
return await send(payload);
} catch (err: any) {
// Analysis: only if error is 400 and "Extra inputs are not permitted"
if (err &&
(err.status === 400 || err.code === 400 || (err.response && err.response.status === 400)) &&
(typeof err.message === 'string' && err.message.includes('Extra inputs are not permitted') ||
(err.response && typeof err.response.data === 'string' && err.response.data.includes('Extra inputs are not permitted')) ||
(err.data && typeof err.data === 'string' && err.data.includes('Extra inputs are not permitted')))) {
// Applying sanitizer
const cleaned = sanitizeAzureDialPayload(payload);
try {
// Second attempt — sanitized payload
return await send(cleaned);
} catch (err2: any) {
throw new Error(`[DIAL Retry] Request failed after sanitize retry: ${err2?.message || err2}`);
}
}
throw err;
}
}

Loading
Loading