Ollama Vision APIの「OpenAI互換」を過信して半日溶かした話

自前の写真管理アプリに AI 画像分析を組み込みたかった。OpenAI や Anthropic のクラウド API は使える状態だったが、プライベートな家族写真を外部に送りたくない。ローカルネットワーク上の Ollama なら画像データが外に出ない。「OpenAI 互換 API があるから既存コードを流用すれば一瞬だろう」——その見積もりは甘かった。

前提環境

| コンポーネント | バージョン / スペック | |---|---| | Ollama | 0.17.4 | | Vision モデル | qwen2.5vl:7b(8.3B params, Q4_K_M, VRAM 約6GB) | | アプリ | Go 1.23 / React 18 | | ネットワーク | Ollama は 192.168.25.111:11434 で稼働 |

アプリには既に OpenAI / Anthropic / Custom の 3 プロバイダーがあり、プロバイダーごとに callOpenAI, callAnthropic, callCustomAPI メソッドが分かれている構成。ここに callOllama を追加する。

罠1: image_url の型が違う

Ollama のドキュメントには「OpenAI 互換」と書いてある。素直に既存の OpenAI 用リクエスト型を再利用した。

// OpenAI 本家の形式
type openAIContent struct {
    Type     string          `json:"type"`
    Text     string          `json:"text,omitempty"`
    ImageURL *openAIImageURL `json:"image_url,omitempty"` // ネストしたオブジェクト
}

type openAIImageURL struct {
    URL string `json:"url"`
}

これで生成される JSON:

{
  "type": "image_url",
  "image_url": {"url": "data:image/png;base64,iVBOR..."}
}

Ollama に投げたら model runner has unexpectedly stopped でクラッシュ。エラーメッセージからは原因が全く読み取れない。

Ollama が実際に期待する形式はこう:

{
  "type": "image_url",
  "image_url": "data:image/png;base64,iVBOR..."
}

image_url がオブジェクトではなくフラット文字列。 ドキュメントにはさらっと書いてあるが、OpenAI の仕様を知っている人ほど見落とす。

解決策として、Ollama 専用の型を定義した:

// Ollama 用: image_url はフラット文字列
type ollamaContent struct {
    Type     string `json:"type"`
    Text     string `json:"text,omitempty"`
    ImageURL string `json:"image_url,omitempty"`
}

type ollamaMessage struct {
    Role    string          `json:"role"`
    Content []ollamaContent `json:"content"`
}

type ollamaRequest struct {
    Model     string          `json:"model"`
    Messages  []ollamaMessage `json:"messages"`
    MaxTokens int             `json:"max_tokens"`
}

「互換」API の型を共有するのではなく、プロバイダーごとに型定義を分離する のが正解だった。Go の型システムが厳密だからこそ、中途半端な共有はバグの温床になる。

罠2: SSRF バリデーションがプライベート IP をブロック

既存の API エンドポイントバリデーションには SSRF 対策が入っていた。

func validateAPIEndpoint(endpoint string) error {
    // ...
    ip := net.ParseIP(host)
    if ip != nil {
        if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() {
            return errors.New("private IP addresses are not allowed")
        }
    }
    // ...
}

OpenAI や Anthropic への接続ではプライベート IP をブロックするのが正しい。しかし Ollama はローカルネットワーク上で動かすのが前提だ。

プロバイダー引数を追加して、Ollama の場合だけプライベート IP チェックをスキップする:

func validateAPIEndpoint(endpoint, provider string) error {
    u, err := url.Parse(endpoint)
    if err != nil {
        return fmt.Errorf("invalid URL: %w", err)
    }

    // 既知のホストは常に許可
    if allowedAIHosts[u.Host] {
        return nil
    }

    // Ollama はプライベートネットワーク前提なのでスキップ
    if provider == "ollama" {
        return nil
    }

    // 他プロバイダーは従来通りプライベート IP をブロック
    // ...
}

セキュリティ緩和は最小限に。provider == "ollama" のときだけ、かつ URL パースが成功した後にのみスキップしている。

罠3: 極小画像でモデルがクラッシュする

上の2つを直してもまだ model runner has unexpectedly stopped が出る。VRAM 不足を疑い、別のモデルを検討し始めたが、テキストのみのリクエストは問題なく動く。

原因の切り分けのために Ollama のネイティブ API (/api/chat) に切り替えて試したところ、別のエラーが返ってきた:

png: invalid format: too much pixel data

テストに使っていた 1x1 ピクセルの PNG が小さすぎた。 Vision モデルの画像処理パイプラインが極小画像を処理できずにクラッシュしていた。100x100 の画像に差し替えたら正常動作した。

# 動かない(1x1 PNG)
curl http://192.168.25.111:11434/api/chat \
  -d '{"model":"qwen2.5vl:7b","messages":[{"role":"user","content":"describe","images":["iVBOR..."]}],"stream":false}'
# → model runner has unexpectedly stopped

# 動く(100x100 PNG)
curl http://192.168.25.111:11434/api/chat \
  -d '{"model":"qwen2.5vl:7b","messages":[{"role":"user","content":"describe","images":["iVBOR...(100x100)..."]}],"stream":false}'
# → {"message":{"content":"The image consists of a solid red background..."}}

実運用ではアップロード画像は数百万ピクセルあるので問題にならないが、ユニットテストでダミー画像を使うときにハマるポイント。テスト用画像は最低 100x100 にしておくのが安全。

実装のポイント

エンドポイントの自動補完

Ollama のベース URL だけ設定すれば /v1/chat/completions を自動付与する:

endpoint := w.config.AI.APIEndpoint
if endpoint == "" {
    endpoint = "http://localhost:11434"
}
if !strings.HasSuffix(endpoint, "/v1/chat/completions") {
    endpoint = strings.TrimRight(endpoint, "/") + "/v1/chat/completions"
}

ユーザーは http://192.168.25.111:11434 だけ入力すればいい。

タイムアウトの分離

Ollama はモデルのコールドスタートに時間がかかる。デフォルトの 60 秒ではタイムアウトするため、Ollama 専用に 120 秒のクライアントを作る:

// 通常プロバイダー: 60秒(NewAIWorker で設定済み)
// Ollama: コールドスタート考慮で120秒
client := &http.Client{Timeout: 120 * time.Second}
resp, err := client.Do(req)

API キーのオプション化

Ollama は通常 API キーなしで動作する。設定画面でも Ollama 選択時は API キー入力を非表示にし、バックエンドでも API キー未設定を許容する:

// 設定ステータス判定
func (s *settingsService) getAIStatus(_ context.Context) AIStatus {
    if s.config.AI.Provider == "" {
        return AIStatus{Error: "not configured"}
    }
    // Ollama は API キー不要
    if s.config.AI.Provider != "ollama" && s.config.AI.APIKey == "" {
        return AIStatus{Error: "not configured"}
    }
    return AIStatus{Configured: true, Provider: s.config.AI.Provider, ...}
}

分析結果の実例

100x100 の赤い画像を qwen2.5vl:7b に投げた結果:

{
  "scene_labels": [
    {"label": "Simple Solid Background", "label_ja": "単色背景", "confidence": 0.95}
  ],
  "objects": [],
  "colors": [
    {"hex": "#FF0000", "percentage": 0.96, "name_ja": "赤"},
    {"hex": "#00FF00", "percentage": 0.02, "name_ja": "緑"}
  ],
  "description": "The image consists of a solid red background with no objects or additional elements present."
}

モデルは ```json で囲んで返すことが多いが、既存の parseAnalysisResponse がマークダウンコードブロックを剥がす処理を持っているので問題ない。GPT-4o と比べると品質は落ちるが、シーンラベル・オブジェクト検出・色分析とも実用レベルで返ってくる。

OpenAI互換を謳う API と付き合うコツ

今回の実装で得た教訓をまとめる。

  1. 型を共有するな。 リクエスト・レスポンスの型はプロバイダーごとに定義する。「互換」は 95% 互換であって 100% ではない
  2. テスト画像のサイズに注意。 Vision モデルのテストで極小画像を使うとモデルランナーがクラッシュする。最低 100x100
  3. エラーメッセージを信用するな。 model runner has unexpectedly stopped は VRAM 不足でもフォーマットエラーでも同じメッセージが出る。ネイティブ API に切り替えて試すと真の原因が見えることがある
  4. SSRF 対策はプロバイダー単位で制御する。 一律ブロックではなく、プロバイダーの特性に応じて最小限の緩和を入れる