🔍

Go × OpenRouter で作る“著名人判定 API”超入門 ― Vision-LLM を 20 分でデプロイ

に公開

完成イメージ

curl -F image=@obama.jpg http://localhost:8080/predict
# => {"isCelebrity":true,"name":"Barack Obama"}

0. 事前準備

ツール 使ったバージョン 備考
Go 1.24+ brew install go
Gin v1.10+ 自動で go get されます
OpenRouter アカウント - API Key 発行

1. .env にキーとモデルをセット

OPENROUTER_API_KEY=sk-...
OPENROUTER_MODEL=meta-llama/llama-3.2-11b-vision-instruct:free
  • モデルは Vision 対応 のものなら何でも OK
    (mistralai/ や anthropic/ 系でも可)

2. ひな型を生成

go mod init celeb-detector
go get github.com/gin-gonic/gin github.com/joho/godotenv

3. main.go --- 完全版

package main

import (
	"bytes"
	"encoding/base64"
	"encoding/json"
	"io"
	"log"
	"net/http"
	"os"
	"regexp"

	"github.com/gin-gonic/gin"
	"github.com/joho/godotenv"
)

/* ============ OpenAI-compatible 型 ============ */
type openaiReq struct {
	Model    string          `json:"model"`
	Messages []openaiMessage `json:"messages"`
}

type openaiMessage struct {
	Role    string        `json:"role"`
	Content []interface{} `json:"content"` // text と image_url を混在
}

type imagePart struct {
	Type     string `json:"type"` // "image_url"
	ImageURL struct {
		URL string `json:"url"`
	} `json:"image_url"`
}

type textPart struct {
	Type string `json:"type"` // "text"
	Text string `json:"text"`
}

type openaiResp struct {
	Choices []struct {
		Message struct {
			Content string `json:"content"`
		} `json:"message"`
	} `json:"choices"`
}

/* ============ メイン ============ */
func main() {
	_ = godotenv.Load()

	key := os.Getenv("OPENROUTER_API_KEY")
	model := os.Getenv("OPENROUTER_MODEL")
	if key == "" || model == "" {
		log.Fatal("OPENROUTER_API_KEY / MODEL を .env に設定してください")
	}

	r := gin.Default()

	r.POST("/predict", func(c *gin.Context) {
		/* 1. 画像を受け取る */
		file, _, err := c.Request.FormFile("image")
		if err != nil {
			c.JSON(http.StatusBadRequest, gin.H{"error": "image form-field required"})
			return
		}
		defer file.Close()

		buf, _ := io.ReadAll(file)
		imgBase64 := "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(buf)

		/* 2. OpenRouter に投げるリクエストを構築 */
		reqBody := openaiReq{
			Model: model,
			Messages: []openaiMessage{{
				Role: "user",
				Content: []interface{}{
					textPart{Type: "text", Text: `You are an AI assistant that tells whether the given face image contains a well-known public figure.
Return strictly JSON like: {"isCelebrity": true, "name": "Full Name"} or {"isCelebrity": false, "name": ""}.`},
					imagePart{Type: "image_url", ImageURL: struct {
						URL string `json:"url"`
					}{URL: imgBase64}},
				},
			}},
		}

		bodyBytes, _ := json.Marshal(reqBody)
		req, _ := http.NewRequest("POST",
			"https://openrouter.ai/api/v1/chat/completions",
			bytes.NewReader(bodyBytes))
		req.Header.Set("Content-Type", "application/json")
		req.Header.Set("Authorization", "Bearer "+key)

		/* 3. 呼び出し */
		resp, err := http.DefaultClient.Do(req)
		if err != nil {
			c.JSON(500, gin.H{"error": err.Error()})
			return
		}
		rawResp, _ := io.ReadAll(resp.Body)
		defer resp.Body.Close()
		log.Printf("status=%d body=%s", resp.StatusCode, rawResp)

		/* 4. OpenAI 互換レスポンスをパース */
		var oaResp openaiResp
		if err := json.Unmarshal(rawResp, &oaResp); err != nil {
			c.JSON(502, gin.H{"error": "openrouter decode failed"})
			return
		}
		if len(oaResp.Choices) == 0 {
			c.JSON(502, gin.H{"error": "no choices returned", "detail": string(rawResp)})
			return
		}

		/* 5. strict JSON が来ているか確認 */
		raw := oaResp.Choices[0].Message.Content
		var out struct {
			IsCelebrity bool   `json:"isCelebrity"`
			Name        string `json:"name"`
		}
		if err := json.Unmarshal([]byte(raw), &out); err != nil {
			// => strict じゃなかったので {...} を抽出して再パース
			re := regexp.MustCompile(`\{.*\}`)
			jsonPart := re.FindString(raw)
			if jsonPart == "" || json.Unmarshal([]byte(jsonPart), &out) != nil {
				c.JSON(500, gin.H{"error": "LLM returned non-JSON", "raw": raw})
				return
			}
		}

		/* 6. 返却 */
		c.JSON(200, out)
	})

	log.Println("listen :8080")
	r.Run(":8080")
}

4. 動作チェック:追加 → 一覧 → 削除 …ならぬ「判定」!

# 起動
go run main.go
# => listen :8080

## 1) 有名人
curl -F image=@obama.jpg http://localhost:8080/predict
# {"isCelebrity":true,"name":"Barack Obama"}

## 2) 有名人でない人物
curl -F image=@myfriend.jpg http://localhost:8080/predict
# {"isCelebrity":false,"name":""}

## 3) モデルが文章を返しても OK(正規表現 fallback)
curl -F image=@abe.jpg http://localhost:8080/predict
# {"isCelebrity":true,"name":"Shinzo Abe"}

5. まとめ 🚀

  • Vision-LLM + OpenRouter で顔写真→著名人判定をローカル API 化
  • Gin のマルチパート受信 → Base64 → Data-URI → OpenRouter
  • strict JSON を要求しつつ、正規表現 fallback で実用強度◎

Discussion