Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@
},
"tags": {
"type": "string",
"required": true,
"description": "Retrieval context keywords - when should this memory surface? Comma-separated (e.g., 'debugging,swift-help' not just 'swift')"
"required": false,
"description": "Optional retrieval tags. Accepts comma-separated text or tag arrays. If omitted/blank, semantic tags are auto-generated from content."
},
"content": {
"type": "string",
Expand All @@ -57,7 +57,7 @@
},

"ai": {
"systemPromptSection": "### create_memory\nSave important information to memory for future conversations. Use this to remember facts about the user, their preferences, important context, or insights.\n\n**Memory Types:**\n- `allocentric`: Facts ABOUT the user (preferences, background, relationships, what they like/dislike)\n- `egoic`: What WORKS for you in this agentic context (approaches, techniques, insights, learnings about how to help them)\n\n**Format:**\n```tool_request\n{\"tool\": \"create_memory\", \"query\": \"TYPE|CONFIDENCE|TAGS|CONTENT\"}\n```\n\n**Parameters (pipe-separated):**\n- TYPE: Either \"allocentric\" or \"egoic\"\n- CONFIDENCE: 0.0-1.0 (how certain you are)\n- TAGS: Retrieval context keywords - when should this memory surface? (e.g., \"debugging,swift-help\" not just \"swift\")\n- CONTENT: The actual fact or insight to remember",
"systemPromptSection": "### create_memory\nSave important information to memory for future conversations. Use this to remember facts about the user, their preferences, important context, or insights.\n\n**Memory Types:**\n- `allocentric`: Facts ABOUT the user (preferences, background, relationships, what they like/dislike)\n- `egoic`: What WORKS for you in this agentic context (approaches, techniques, insights, learnings about how to help them)\n\n**Format:**\n```tool_request\n{\"tool\": \"create_memory\", \"query\": \"TYPE|CONFIDENCE|TAGS|CONTENT\"}\n```\n\n**Parameters (pipe-separated):**\n- TYPE: Either \"allocentric\" or \"egoic\"\n- CONFIDENCE: 0.0-1.0 (how certain you are)\n- TAGS: Optional retrieval context tags (free-form and domain-specific). Comma-separated if provided. Leave blank (`||`) to auto-generate semantic tags from content.\n- CONTENT: The actual fact or insight to remember",
"usageExamples": [
{
"description": "Remember user language preference",
Expand All @@ -70,6 +70,10 @@
{
"description": "Remember project context",
"input": "allocentric|0.85|axon,architecture|User is building Axon, an AI assistant app with co-sovereignty features"
},
{
"description": "Allow automatic semantic tag generation",
"input": "allocentric|0.82||User now prefers actionable code-review findings first, then short summary"
}
],
"whenToUse": [
Expand Down
103 changes: 90 additions & 13 deletions Axon/Services/Conversation/AttachmentMimePolicyService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -111,26 +111,36 @@ enum AttachmentMimePolicyService {
resolved = ConversationModelResolver.resolveGlobal(settings: settings)
}

let provider = resolved.normalizedProvider
let modelId = resolved.modelId
let providerName = resolved.providerName
return resolvePolicy(
provider: resolved.normalizedProvider,
modelId: resolved.modelId,
providerName: resolved.providerName,
conversationId: conversationId,
settings: settings
)
}

static func resolvePolicy(
provider: String,
modelId: String,
providerName: String,
conversationId: String?,
settings: AppSettings
) -> AttachmentMimePolicy {
let normalizedProvider = normalizedProviderKey(provider)
let patternsByType: [MessageAttachment.AttachmentType: [String]]
switch provider {
switch normalizedProvider {
case "anthropic":
patternsByType = policy(
image: ["image/*"],
document: ["application/pdf", "text/*"]
)

case "openai":
let supportsAudio = modelId.lowercased().contains("4o")
|| modelId.lowercased().contains("audio")
|| modelId.lowercased().contains("realtime")
patternsByType = policy(
image: ["image/*"],
document: [],
audio: supportsAudio ? ["audio/*"] : [],
audio: supportsOpenAIAudio(modelId: modelId) ? ["audio/*"] : [],
video: []
)

Expand All @@ -157,14 +167,25 @@ enum AttachmentMimePolicyService {
patternsByType = policy(image: supportsVision ? ["image/*"] : [])

case "openai-compatible":
patternsByType = customProviderPolicy(conversationId: conversationId, settings: settings, fallbackModelCode: modelId)
// Keep strict transport parity with chat-completions payload builders:
// OpenAI-compatible transport currently supports image + audio only.
let rawPolicy = customProviderPolicy(
conversationId: conversationId,
settings: settings,
fallbackModelCode: modelId
)
patternsByType = enforceTransportParity(
rawPolicy,
provider: normalizedProvider,
modelId: modelId
)

default:
patternsByType = policy()
}

return AttachmentMimePolicy(
provider: provider,
provider: normalizedProvider,
modelId: modelId,
providerName: providerName,
allowedPatternsByType: patternsByType
Expand Down Expand Up @@ -345,7 +366,11 @@ enum AttachmentMimePolicyService {
settings: AppSettings,
fallbackModelCode: String
) -> [MessageAttachment.AttachmentType: [String]] {
guard let (provider, model) = resolveCustomSelection(conversationId: conversationId, settings: settings) else {
guard let (provider, model) = resolveCustomSelection(
conversationId: conversationId,
settings: settings,
preferredModelCode: fallbackModelCode
) else {
return patternsToTypedPolicy(fallbackCustomPatterns(modelCode: fallbackModelCode))
}

Expand All @@ -360,7 +385,8 @@ enum AttachmentMimePolicyService {

private static func resolveCustomSelection(
conversationId: String?,
settings: AppSettings
settings: AppSettings,
preferredModelCode: String?
) -> (provider: CustomProviderConfig, model: CustomModelConfig?)? {
var selectedProviderId = settings.selectedCustomProviderId
var selectedModelId = settings.selectedCustomModelId
Expand All @@ -379,7 +405,12 @@ enum AttachmentMimePolicyService {
return nil
}

let model = provider.models.first(where: { $0.id == selectedModelId })
let trimmedPreferredModelCode = preferredModelCode?.trimmingCharacters(in: .whitespacesAndNewlines)
let model = provider.models.first(where: { model in
guard let preferred = trimmedPreferredModelCode, !preferred.isEmpty else { return false }
return model.modelCode.caseInsensitiveCompare(preferred) == .orderedSame
})
?? provider.models.first(where: { $0.id == selectedModelId })
?? provider.models.first

return (provider, model)
Expand Down Expand Up @@ -451,6 +482,52 @@ enum AttachmentMimePolicyService {
return mimeAliases[base] ?? base
}

private static func supportsOpenAIAudio(modelId: String) -> Bool {
let normalized = modelId.lowercased()
return normalized.contains("4o")
|| normalized.contains("audio")
|| normalized.contains("realtime")
}

private static func normalizedProviderKey(_ provider: String) -> String {
let normalized = provider.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
return normalized == "xai" ? "grok" : normalized
}

private static func enforceTransportParity(
_ policyByType: [MessageAttachment.AttachmentType: [String]],
provider: String,
modelId: String
) -> [MessageAttachment.AttachmentType: [String]] {
let supportedTypes = transportSupportedAttachmentTypes(provider: provider, modelId: modelId)
var filtered = policy()

for type in allAttachmentTypes {
filtered[type] = supportedTypes.contains(type) ? (policyByType[type] ?? []) : []
}

return filtered
}

private static func transportSupportedAttachmentTypes(
provider: String,
modelId: String
) -> Set<MessageAttachment.AttachmentType> {
switch provider {
case "openai":
var supported: Set<MessageAttachment.AttachmentType> = [.image]
if supportsOpenAIAudio(modelId: modelId) {
supported.insert(.audio)
}
return supported
case "openai-compatible":
// Current OpenAI-compatible payload builders only serialize image + audio.
return [.image, .audio]
default:
return Set(allAttachmentTypes)
}
}

private static func isValidMimeToken(_ token: String) -> Bool {
guard !token.isEmpty else { return false }
let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyz0123456789!#$&^_.+-")
Expand Down
8 changes: 7 additions & 1 deletion Axon/Services/Conversation/ConversationService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -630,7 +630,13 @@ class ConversationService: ObservableObject {
let resolvedModelParams = runtimeOverrides.modelParams

if !attachments.isEmpty {
let policy = AttachmentMimePolicyService.resolvePolicy(conversationId: conversationId, settings: settings)
let policy = AttachmentMimePolicyService.resolvePolicy(
provider: providerString,
modelId: modelId,
providerName: providerDisplayName,
conversationId: conversationId,
settings: settings
)
let validation = AttachmentMimePolicyService.validate(attachments: attachments, policy: policy)
if case .rejected(let failures) = validation {
throw APIError.networkError(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2544,12 +2544,15 @@ class OnDeviceConversationOrchestrator: ConversationOrchestrator {
"format": format
]
])
} else if attachment.url != nil {
print("[OnDeviceOrchestrator] OpenAI/OpenAI-compatible payload dropped audio URL attachment '\(attachment.name ?? attachment.id)' (\(mimeType)); input_audio requires inline/base64 data.")
}
// Note: OpenAI doesn't support audio URLs directly

case .document, .video:
// OpenAI doesn't natively support PDFs or video in chat completions
// Skip these for now - would need separate handling
print("[OnDeviceOrchestrator] OpenAI/OpenAI-compatible payload dropped unsupported \(attachment.type.rawValue) attachment '\(attachment.name ?? attachment.id)' (\(mimeType)).")
continue
}
}
Expand Down
7 changes: 6 additions & 1 deletion Axon/Services/Security/DeviceIdentity.swift
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,12 @@ class DeviceIdentity {

/// Generate a signature for this device (useful for API auth)
func generateDeviceSignature(data: String) -> String {
let key = SymmetricKey(data: Data(getDeviceId().utf8))
generateDeviceSignature(data: data, usingDeviceId: getDeviceId())
}

/// Generate a deterministic signature using an explicit device ID context.
func generateDeviceSignature(data: String, usingDeviceId deviceId: String) -> String {
let key = SymmetricKey(data: Data(deviceId.utf8))
let signature = HMAC<SHA256>.authenticationCode(for: Data(data.utf8), using: key)
return Data(signature).base64EncodedString()
}
Expand Down
Loading
Loading