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 './providers/claudeAttachmentAdapter';
|
||||
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