feat(attachments): add opencode vision adapter
This commit is contained in:
parent
935c522846
commit
c20293f4de
3 changed files with 228 additions and 0 deletions
|
|
@ -1,3 +1,4 @@
|
||||||
export * from './infrastructure/attachmentArtifactStore';
|
export * from './infrastructure/attachmentArtifactStore';
|
||||||
export * from './providers/claudeAttachmentAdapter';
|
export * from './providers/claudeAttachmentAdapter';
|
||||||
export * from './providers/codexNativeAttachmentAdapter';
|
export * from './providers/codexNativeAttachmentAdapter';
|
||||||
|
export * from './providers/opencodeAttachmentAdapter';
|
||||||
|
|
|
||||||
|
|
@ -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]');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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}]`,
|
||||||
|
}));
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue