feat(attachments): add opencode vision adapter

This commit is contained in:
777genius 2026-05-09 01:18:51 +03:00
parent 935c522846
commit c20293f4de
3 changed files with 228 additions and 0 deletions

View file

@ -1,3 +1,4 @@
export * from './infrastructure/attachmentArtifactStore';
export * from './providers/claudeAttachmentAdapter';
export * from './providers/codexNativeAttachmentAdapter';
export * from './providers/opencodeAttachmentAdapter';

View file

@ -0,0 +1,105 @@
import {
buildOpenCodeAttachmentDeliveryParts,
redactOpenCodeFilePartsForDiagnostics,
} from './opencodeAttachmentAdapter';
import type { AttachmentPayload } from '@shared/types';
function attachment(overrides: Partial<AttachmentPayload> = {}): AttachmentPayload {
return {
id: 'att_1',
filename: 'red.png',
mimeType: 'image/png',
size: 3,
data: Buffer.from([1, 2, 3]).toString('base64'),
...overrides,
};
}
describe('OpenCode attachment adapter', () => {
it('keeps text-only messages on the legacy text path', () => {
expect(
buildOpenCodeAttachmentDeliveryParts({
text: 'hello',
model: 'openrouter/moonshotai/kimi-k2.6',
})
).toEqual({
kind: 'legacy_text',
text: 'hello',
fileParts: [],
diagnostics: [],
});
});
it('serializes verified OpenCode vision models as file parts', () => {
const result = buildOpenCodeAttachmentDeliveryParts({
text: 'What color?',
model: 'openrouter/moonshotai/kimi-k2.6',
attachments: [attachment()],
});
expect(result.kind).toBe('text_with_file_parts');
expect(result.fileParts).toEqual([
{
type: 'file',
mime: 'image/png',
url: `data:image/png;base64,${attachment().data}`,
filename: 'red.png',
},
]);
expect(result.diagnostics.join('\n')).not.toContain(attachment().data);
});
it('allows verified GLM 4.5V image delivery', () => {
expect(() =>
buildOpenCodeAttachmentDeliveryParts({
text: 'What color?',
model: 'openrouter/z-ai/glm-4.5v',
attachments: [attachment()],
})
).not.toThrow();
});
it('blocks known non-vision OpenCode models before runtime send', () => {
expect(() =>
buildOpenCodeAttachmentDeliveryParts({
text: 'What color?',
model: 'openrouter/z-ai/glm-5.1',
attachments: [attachment()],
})
).toThrow(/not verified for image attachments/);
});
it('blocks unknown OpenCode model image delivery by default', () => {
expect(() =>
buildOpenCodeAttachmentDeliveryParts({
text: 'What color?',
model: 'openrouter/example/new-model',
attachments: [attachment()],
})
).toThrow(/unknown image support/);
});
it('rejects non-image attachments before provider send', () => {
expect(() =>
buildOpenCodeAttachmentDeliveryParts({
text: 'Read PDF',
model: 'openrouter/moonshotai/kimi-k2.6',
attachments: [attachment({ filename: 'a.pdf', mimeType: 'application/pdf' })],
})
).toThrow(/OpenCode currently supports image attachments only/);
});
it('redacts data URLs from diagnostics', () => {
const redacted = redactOpenCodeFilePartsForDiagnostics([
{
type: 'file',
mime: 'image/png',
url: `data:image/png;base64,${attachment().data}`,
filename: 'red.png',
},
]);
expect(redacted[0]!.url).toBe('[redacted data URL: image/png]');
});
});

View file

@ -0,0 +1,122 @@
import {
AgentAttachmentError,
resolveAgentAttachmentCapability,
} from '@features/agent-attachments/core/domain';
import type { AttachmentPayload } from '@shared/types';
export type OpenCodeFilePartMimeType = 'image/png' | 'image/jpeg' | 'image/webp';
export interface OpenCodeFilePart {
type: 'file';
mime: OpenCodeFilePartMimeType;
url: string;
filename: string;
}
export interface OpenCodeAttachmentDeliveryParts {
kind: 'legacy_text' | 'text_with_file_parts';
text: string;
fileParts: OpenCodeFilePart[];
diagnostics: string[];
}
export interface BuildOpenCodeAttachmentDeliveryPartsInput {
text: string;
model: string;
attachments?: AttachmentPayload[];
}
function assertOpenCodeImageMimeType(
mimeType: string
): asserts mimeType is OpenCodeFilePartMimeType {
if (mimeType === 'image/png' || mimeType === 'image/jpeg' || mimeType === 'image/webp') {
return;
}
throw new AgentAttachmentError(
'attachment_type_unsupported',
`OpenCode currently supports image attachments only; unsupported MIME: ${mimeType}`,
{ providerId: 'opencode', retryable: false }
);
}
function assertOpenCodeVisionCapability(model: string): void {
const capability = resolveAgentAttachmentCapability({
providerId: 'opencode',
model,
});
if (capability.supportsImages) {
return;
}
const code =
capability.reason === 'known_non_vision_model' || capability.reason === 'unknown_model'
? 'attachment_model_unsupported'
: 'attachment_type_unsupported';
throw new AgentAttachmentError(code, capability.displayText, {
providerId: 'opencode',
model,
retryable: false,
safeDetails: {
reason: capability.reason,
},
});
}
function formatBytes(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B';
if (bytes < 1024) return `${bytes} B`;
const kib = bytes / 1024;
if (kib < 1024) return `${kib.toFixed(1)} KB`;
return `${(kib / 1024).toFixed(1)} MB`;
}
export function buildOpenCodeAttachmentDeliveryParts(
input: BuildOpenCodeAttachmentDeliveryPartsInput
): OpenCodeAttachmentDeliveryParts {
const attachments = input.attachments ?? [];
if (attachments.length === 0) {
return {
kind: 'legacy_text',
text: input.text,
fileParts: [],
diagnostics: [],
};
}
assertOpenCodeVisionCapability(input.model);
const fileParts: OpenCodeFilePart[] = [];
const diagnostics: string[] = [];
for (const attachment of attachments) {
assertOpenCodeImageMimeType(attachment.mimeType);
fileParts.push({
type: 'file',
mime: attachment.mimeType,
url: `data:${attachment.mimeType};base64,${attachment.data}`,
filename: attachment.filename,
});
diagnostics.push(
`prepared OpenCode image file part ${attachment.filename} (${attachment.mimeType}, ${formatBytes(
attachment.size
)}) for ${input.model}`
);
}
return {
kind: 'text_with_file_parts',
text: input.text,
fileParts,
diagnostics,
};
}
export function redactOpenCodeFilePartsForDiagnostics(
parts: OpenCodeFilePart[]
): OpenCodeFilePart[] {
return parts.map((part) => ({
...part,
url: `[redacted data URL: ${part.mime}]`,
}));
}